Browse Source

feat: add relocation runway calculator

Made-with: Cursor
pull/6453/head
Priyanka Punukollu 1 month ago
parent
commit
591af17507
  1. 62
      agent/evals/test_relocation_runway.py
  2. 46
      agent/graph.py
  3. 287
      agent/tools/relocation_runway.py

62
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"]

46
agent/graph.py

@ -411,6 +411,52 @@ async def classify_node(state: AgentState) -> AgentState:
return {**state, "query_type": "compliance+tax"} return {**state, "query_type": "compliance+tax"}
return {**state, "query_type": "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 --- # --- Wealth Bridge — down payment, job offer COL, global city data ---
# Checked before real estate so "can I afford" doesn't fall through to snapshot # Checked before real estate so "can I afford" doesn't fall through to snapshot
if is_real_estate_enabled(): if is_real_estate_enabled():

287
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",
}
Loading…
Cancel
Save