From 591af17507669ca42cc48c8da12abf5071f1f4bd Mon Sep 17 00:00:00 2001 From: Priyanka Punukollu Date: Thu, 26 Feb 2026 16:35:44 -0600 Subject: [PATCH] feat: add relocation runway calculator Made-with: Cursor --- agent/evals/test_relocation_runway.py | 62 ++++++ agent/graph.py | 46 +++++ agent/tools/relocation_runway.py | 287 ++++++++++++++++++++++++++ 3 files changed, 395 insertions(+) create mode 100644 agent/evals/test_relocation_runway.py create mode 100644 agent/tools/relocation_runway.py diff --git a/agent/evals/test_relocation_runway.py b/agent/evals/test_relocation_runway.py new file mode 100644 index 000000000..0487ee55a --- /dev/null +++ b/agent/evals/test_relocation_runway.py @@ -0,0 +1,62 @@ +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools')) +from relocation_runway import calculate_relocation_runway + + +def test_runway_seattle_vs_austin(): + result = calculate_relocation_runway( + current_salary=120000, offer_salary=180000, + current_city="Austin", destination_city="Seattle", + portfolio_value=94000 + ) + assert result["destination_monthly"]["monthly_surplus"] is not None + assert "months_to_6mo_emergency_fund" in result["milestones_if_you_move"] + assert "verdict" in result + assert "key_insight" in result + assert result["scenario"]["offer"]["city"] == "Seattle" + + +def test_runway_impossible_offer(): + result = calculate_relocation_runway( + current_salary=120000, offer_salary=40000, + current_city="Austin", destination_city="San Francisco", + portfolio_value=94000 + ) + assert result is not None + assert "destination_monthly" in result + surplus = result["destination_monthly"]["monthly_surplus"] + warning = result["destination_monthly"].get("monthly_surplus_warning", False) + assert surplus <= 0 or warning is True + + +def test_runway_moving_to_affordable_city(): + result = calculate_relocation_runway( + current_salary=120000, offer_salary=110000, + current_city="San Francisco", destination_city="Austin", + portfolio_value=50000 + ) + assert "verdict" in result + assert result["destination_monthly"]["housing_cost"] < 3000 + + +def test_runway_global_city(): + result = calculate_relocation_runway( + current_salary=100000, offer_salary=150000, + current_city="Austin", destination_city="Berlin", + portfolio_value=75000 + ) + assert "verdict" in result + assert result["scenario"]["offer"]["city"] == "Berlin" + + +def test_runway_returns_comparison(): + result = calculate_relocation_runway( + current_salary=120000, offer_salary=180000, + current_city="Austin", destination_city="Denver", + portfolio_value=94000 + ) + assert "milestones_if_you_move" in result + assert "milestones_if_you_stay" in result + assert "months_to_down_payment_20pct" in result["milestones_if_you_move"] + assert "months_to_down_payment_20pct" in result["milestones_if_you_stay"] diff --git a/agent/graph.py b/agent/graph.py index bd29ef7a2..bf51c6026 100644 --- a/agent/graph.py +++ b/agent/graph.py @@ -411,6 +411,52 @@ async def classify_node(state: AgentState) -> AgentState: return {**state, "query_type": "compliance+tax"} return {**state, "query_type": "tax"} + # --- Relocation Runway Calculator --- + relocation_runway_kws = [ + "how long until", "runway", "financially stable", + "if i move", "relocation timeline", "stable if", + "how long to feel stable", "feel stable after", + ] + if any(kw in query for kw in relocation_runway_kws): + return {**state, "query_type": "relocation_runway"} + + # --- Wealth Gap Visualizer --- + wealth_gap_kws = [ + "am i behind", "am i on track", "wealth gap", + "how am i doing financially", "ahead or behind", + "net worth compared", "am i ahead", + "am i behind for my age", "retirement on track", + ] + if any(kw in query for kw in wealth_gap_kws): + return {**state, "query_type": "wealth_gap"} + + # --- Life Decision Advisor --- + life_decision_kws = [ + "should i take", "help me decide", "what should i do", + "is it worth it", "advise me", "what do you think", + "should i move", "should i accept", + ] + if any(kw in query for kw in life_decision_kws): + return {**state, "query_type": "life_decision"} + + # --- Equity Unlock Advisor --- + equity_unlock_kws = [ + "home equity", "refinance", "cash out", + "equity options", "what should i do with my equity", + ] + if any(kw in query for kw in equity_unlock_kws): + return {**state, "query_type": "equity_unlock"} + + # --- Family Financial Planner --- + family_planner_kws = [ + "afford a family", "afford a baby", "afford kids", + "childcare costs", "financial impact of children", + "can i afford to have", "family planning", + "having kids", + ] + if any(kw in query for kw in family_planner_kws): + return {**state, "query_type": "family_planner"} + # --- Wealth Bridge — down payment, job offer COL, global city data --- # Checked before real estate so "can I afford" doesn't fall through to snapshot if is_real_estate_enabled(): diff --git a/agent/tools/relocation_runway.py b/agent/tools/relocation_runway.py new file mode 100644 index 000000000..aa5d28e70 --- /dev/null +++ b/agent/tools/relocation_runway.py @@ -0,0 +1,287 @@ +""" +Relocation Runway Calculator +Answers: "How long until I feel financially stable if I move?" +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + from real_estate import MOCK_DATA as AUSTIN_DATA +except ImportError: + AUSTIN_DATA = {} + +try: + from teleport_api import get_city_housing_data + TELEPORT_AVAILABLE = True +except ImportError: + TELEPORT_AVAILABLE = False + + def get_city_housing_data(city): + return {"MedianRentMonthly": 2000, "median_price": 400000} + + +def estimate_take_home(annual_salary: float, city_name: str = "") -> float: + if annual_salary <= 44725: + federal_rate = 0.12 + elif annual_salary <= 95375: + federal_rate = 0.22 + elif annual_salary <= 200000: + federal_rate = 0.24 + else: + federal_rate = 0.32 + + fica = 0.0765 + + no_income_tax = [ + "tx", "wa", "fl", "nv", "tn", "wy", "sd", "ak", + "texas", "washington", "florida", "nevada", + "tennessee", "wyoming", "south dakota", "alaska", + "austin", "seattle", "miami", "nashville", + "dallas", "houston", "san antonio", + ] + + city_lower = city_name.lower() + has_state_tax = not any(s in city_lower for s in no_income_tax) + state_rate = 0.05 if has_state_tax else 0.0 + + total_rate = federal_rate + fica + state_rate + annual_take_home = annual_salary * (1 - total_rate) + return annual_take_home / 12 + + +def get_city_data_safe(city_name: str) -> dict: + """Gets city data from ACTRIS for Austin areas, Teleport for everything else. + Never crashes.""" + austin_keywords = [ + "austin", "travis", "williamson", "hays", "bastrop", + "caldwell", "round rock", "cedar park", "georgetown", + "kyle", "buda", "san marcos", "lockhart", "bastrop", + "elgin", "leander", "pflugerville", "manor", "del valle", + ] + + city_lower = city_name.lower() + + for keyword in austin_keywords: + if keyword in city_lower: + if any(k in city_lower for k in [ + "round rock", "cedar park", "georgetown", "leander", "williamson" + ]): + return AUSTIN_DATA.get( + "williamson_county", + {"MedianRentMonthly": 1995, "median_price": 403500}, + ) + elif any(k in city_lower for k in ["kyle", "buda", "san marcos", "wimberley", "hays"]): + return AUSTIN_DATA.get( + "hays_county", + {"MedianRentMonthly": 1937, "median_price": 344500}, + ) + elif any(k in city_lower for k in ["bastrop", "elgin"]): + return AUSTIN_DATA.get( + "bastrop_county", + {"MedianRentMonthly": 1860, "median_price": 335970}, + ) + elif any(k in city_lower for k in ["lockhart", "caldwell"]): + return AUSTIN_DATA.get( + "caldwell_county", + {"MedianRentMonthly": 1750, "median_price": 237491}, + ) + else: + return AUSTIN_DATA.get( + "austin", + {"MedianRentMonthly": 2100, "median_price": 522500}, + ) + + if TELEPORT_AVAILABLE: + try: + import asyncio + # get_city_housing_data is async — run it synchronously + data = asyncio.run(get_city_housing_data(city_name)) + if data and "MedianRentMonthly" in data: + return data + except Exception: + pass + + FALLBACK_RENTS = { + "san francisco": {"MedianRentMonthly": 3200, "median_price": 1350000}, + "seattle": {"MedianRentMonthly": 2400, "median_price": 850000}, + "new york": {"MedianRentMonthly": 3800, "median_price": 750000}, + "denver": {"MedianRentMonthly": 1900, "median_price": 565000}, + "chicago": {"MedianRentMonthly": 1850, "median_price": 380000}, + "miami": {"MedianRentMonthly": 2800, "median_price": 620000}, + "boston": {"MedianRentMonthly": 3100, "median_price": 720000}, + "los angeles": {"MedianRentMonthly": 2900, "median_price": 950000}, + "nashville": {"MedianRentMonthly": 1800, "median_price": 450000}, + "dallas": {"MedianRentMonthly": 1700, "median_price": 380000}, + "london": {"MedianRentMonthly": 2800, "median_price": 720000}, + "toronto": {"MedianRentMonthly": 2300, "median_price": 980000}, + "sydney": {"MedianRentMonthly": 2600, "median_price": 1100000}, + "berlin": {"MedianRentMonthly": 1600, "median_price": 520000}, + "tokyo": {"MedianRentMonthly": 1800, "median_price": 650000}, + "paris": {"MedianRentMonthly": 2200, "median_price": 800000}, + } + + for key, val in FALLBACK_RENTS.items(): + if key in city_lower: + return val + + return { + "MedianRentMonthly": 2000, + "median_price": 450000, + "city": city_name, + "data_source": "estimate", + } + + +def calculate_relocation_runway( + current_salary: float, + offer_salary: float, + current_city: str, + destination_city: str, + portfolio_value: float, + current_savings_rate: float = 0.15, +) -> dict: + """Calculate how long until financially stable after relocating.""" + + # Step 1: Get city data + dest_data = get_city_data_safe(destination_city) + curr_data = get_city_data_safe(current_city) + + # Step 2: Monthly take-home + dest_take_home = estimate_take_home(offer_salary, destination_city) + curr_take_home = estimate_take_home(current_salary, current_city) + + # Step 3: Monthly costs + def _monthly_costs(city_data: dict) -> tuple: + rent = city_data.get("MedianRentMonthly", 2000) + food_transport = rent * 0.8 + utilities_misc = rent * 0.3 + total = rent + food_transport + utilities_misc + return rent, total + + dest_rent, dest_total_costs = _monthly_costs(dest_data) + curr_rent, curr_total_costs = _monthly_costs(curr_data) + + # Step 4: Monthly surplus + dest_surplus = dest_take_home - dest_total_costs + curr_surplus = curr_take_home - curr_total_costs + dest_surplus_warning = dest_surplus <= 0 + + # Step 5: Milestones for destination + dest_price = dest_data.get("ListPrice", dest_data.get("median_price", 500000)) + dest_down_payment = dest_price * 0.20 + dest_emergency_3mo = dest_total_costs * 3 + dest_emergency_6mo = dest_total_costs * 6 + portfolio_liquid = portfolio_value * 0.1 + portfolio_down = portfolio_value * 0.2 + + if dest_surplus > 0: + months_to_3mo_dest = int(max(0, (dest_emergency_3mo - portfolio_liquid) / dest_surplus)) + months_to_6mo_dest = int(max(0, (dest_emergency_6mo - portfolio_liquid) / dest_surplus)) + months_to_down_dest = int(max(0, (dest_down_payment - portfolio_down) / dest_surplus)) + else: + months_to_3mo_dest = months_to_6mo_dest = months_to_down_dest = 9999 + + # Step 6: Milestones for current city + curr_price = curr_data.get("ListPrice", curr_data.get("median_price", 500000)) + curr_down_payment = curr_price * 0.20 + curr_emergency_3mo = curr_total_costs * 3 + curr_emergency_6mo = curr_total_costs * 6 + + if curr_surplus > 0: + months_to_3mo_curr = int(max(0, (curr_emergency_3mo - portfolio_liquid) / curr_surplus)) + months_to_6mo_curr = int(max(0, (curr_emergency_6mo - portfolio_liquid) / curr_surplus)) + months_to_down_curr = int(max(0, (curr_down_payment - portfolio_down) / curr_surplus)) + else: + months_to_3mo_curr = months_to_6mo_curr = months_to_down_curr = 9999 + + # Step 7: Build verdict + salary_delta = offer_salary - current_salary + salary_pct = (salary_delta / current_salary) * 100 if current_salary > 0 else 0 + surplus_delta = dest_surplus - curr_surplus + + if dest_surplus_warning: + verdict = ( + f"Warning: The ${offer_salary:,.0f} offer in {destination_city} leaves you " + f"with negative monthly surplus. The cost of living exceeds take-home pay." + ) + key_insight = ( + f"You would need at least ${dest_total_costs * 12:,.0f}/yr just to cover " + f"basic living costs in {destination_city}." + ) + elif surplus_delta > 500: + verdict = ( + f"Strong move: The {destination_city} offer gives you " + f"${dest_surplus:,.0f}/mo surplus vs ${curr_surplus:,.0f}/mo now — " + f"${surplus_delta:,.0f}/mo improvement." + ) + key_insight = ( + f"Despite the higher cost of living, the {salary_pct:+.1f}% salary bump " + f"in {destination_city} meaningfully improves your monthly runway." + ) + elif surplus_delta > 0: + verdict = ( + f"Marginal improvement: {destination_city} gives slightly more surplus " + f"(${dest_surplus:,.0f}/mo vs ${curr_surplus:,.0f}/mo now)." + ) + key_insight = ( + f"The salary increase is mostly absorbed by {destination_city}'s higher costs. " + f"Negotiate for more before accepting." + ) + elif surplus_delta > -300: + verdict = ( + f"Roughly equivalent: {destination_city} surplus (${dest_surplus:,.0f}/mo) " + f"is close to your current (${curr_surplus:,.0f}/mo)." + ) + key_insight = ( + f"The {salary_pct:+.1f}% salary change is offset by {destination_city}'s cost " + f"of living. Non-financial factors should decide this." + ) + else: + verdict = ( + f"Financial step back: Moving to {destination_city} reduces your monthly " + f"surplus by ${abs(surplus_delta):,.0f}/mo." + ) + key_insight = ( + f"The higher salary does not cover {destination_city}'s cost premium. " + f"You would need ~${offer_salary - salary_delta + abs(surplus_delta) * 12:,.0f}/yr " + f"to maintain your current financial position." + ) + + return { + "scenario": { + "current": {"city": current_city, "salary": current_salary}, + "offer": {"city": destination_city, "salary": offer_salary}, + }, + "destination_monthly": { + "take_home": round(dest_take_home), + "housing_cost": round(dest_rent), + "total_living_costs": round(dest_total_costs), + "monthly_surplus": round(dest_surplus), + "monthly_surplus_warning": dest_surplus_warning, + }, + "current_monthly": { + "take_home": round(curr_take_home), + "housing_cost": round(curr_rent), + "total_living_costs": round(curr_total_costs), + "monthly_surplus": round(curr_surplus), + }, + "milestones_if_you_move": { + "months_to_3mo_emergency_fund": months_to_3mo_dest, + "months_to_6mo_emergency_fund": months_to_6mo_dest, + "months_to_down_payment_20pct": months_to_down_dest, + "down_payment_target": round(dest_down_payment), + "destination_median_home_price": round(dest_price), + }, + "milestones_if_you_stay": { + "months_to_3mo_emergency_fund": months_to_3mo_curr, + "months_to_6mo_emergency_fund": months_to_6mo_curr, + "months_to_down_payment_20pct": months_to_down_curr, + "down_payment_target": round(curr_down_payment), + "current_median_home_price": round(curr_price), + }, + "verdict": verdict, + "key_insight": key_insight, + "data_source": "ACTRIS MLS Jan 2026 (Austin) + Teleport API (global) + fallback estimates", + }