mirror of https://github.com/ghostfolio/ghostfolio
3 changed files with 395 additions and 0 deletions
@ -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"] |
|||
@ -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…
Reference in new issue