mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
Create realestate_strategy.py with simulate_real_estate_strategy(). All rate parameters (appreciation, rent_yield, mortgage_rate, market_return) default to None — sensible fallbacks applied inside the function body, clearly labeled as starting points not predictions. Adds disclaimer, how_to_adjust, and user_provided flag in assumptions. Adds test_realestate_strategy.py with 7 passing tests. Made-with: Cursorpull/6453/head
2 changed files with 362 additions and 0 deletions
@ -0,0 +1,146 @@ |
|||
""" |
|||
Tests for realestate_strategy.py — simulate_real_estate_strategy() |
|||
|
|||
Tests: |
|||
1. test_basic_strategy_returns_expected_shape |
|||
2. test_user_provided_appreciation_overrides_default |
|||
3. test_conservative_preset_lower_than_optimistic |
|||
4. test_disclaimer_and_how_to_adjust_present |
|||
5. test_timeline_length_matches_total_years |
|||
6. test_single_property_no_rentals |
|||
7. test_net_worth_grows_over_time |
|||
""" |
|||
|
|||
import os |
|||
import sys |
|||
|
|||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
|||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools")) |
|||
|
|||
import pytest |
|||
from realestate_strategy import simulate_real_estate_strategy |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 1 — basic shape |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def test_basic_strategy_returns_expected_shape(): |
|||
result = simulate_real_estate_strategy( |
|||
initial_portfolio_value=94000, |
|||
annual_income=120000, |
|||
first_home_price=400000, |
|||
total_years=10, |
|||
) |
|||
assert "strategy" in result |
|||
assert "timeline" in result |
|||
assert "final_picture" in result |
|||
assert "disclaimer" in result |
|||
assert "how_to_adjust" in result |
|||
|
|||
fp = result["final_picture"] |
|||
assert "total_net_worth" in fp |
|||
assert "total_real_estate_equity" in fp |
|||
assert "investment_portfolio" in fp |
|||
assert fp["total_net_worth"] > 0 |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 2 — user-provided appreciation overrides default |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def test_user_provided_appreciation_overrides_default(): |
|||
result_default = simulate_real_estate_strategy( |
|||
94000, 120000, 400000, total_years=10 |
|||
) |
|||
result_custom = simulate_real_estate_strategy( |
|||
94000, 120000, 400000, total_years=10, |
|||
annual_appreciation=0.02 # conservative |
|||
) |
|||
default_equity = result_default["final_picture"]["total_real_estate_equity"] |
|||
custom_equity = result_custom["final_picture"]["total_real_estate_equity"] |
|||
# Lower appreciation → lower real estate equity |
|||
assert custom_equity < default_equity |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 3 — conservative numbers produce lower net worth than optimistic |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def test_conservative_preset_lower_than_optimistic(): |
|||
result_conservative = simulate_real_estate_strategy( |
|||
94000, 120000, 400000, total_years=10, |
|||
annual_appreciation=0.02, |
|||
annual_rent_yield=0.06, |
|||
annual_market_return=0.05, |
|||
) |
|||
result_optimistic = simulate_real_estate_strategy( |
|||
94000, 120000, 400000, total_years=10, |
|||
annual_appreciation=0.06, |
|||
annual_rent_yield=0.10, |
|||
annual_market_return=0.09, |
|||
) |
|||
assert ( |
|||
result_optimistic["final_picture"]["total_net_worth"] |
|||
> result_conservative["final_picture"]["total_net_worth"] |
|||
) |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 4 — disclaimer and how_to_adjust fields present |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def test_disclaimer_and_how_to_adjust_present(): |
|||
result = simulate_real_estate_strategy( |
|||
94000, 120000, 400000 |
|||
) |
|||
assert "disclaimer" in result |
|||
assert "how_to_adjust" in result |
|||
assert "assumptions" in result["strategy"] |
|||
assert "note" in result["strategy"]["assumptions"] |
|||
# Disclaimer should mention "planning tool" or "prediction" |
|||
assert "planning tool" in result["disclaimer"] or "prediction" in result["disclaimer"] |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 5 — timeline length matches total_years |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def test_timeline_length_matches_total_years(): |
|||
result = simulate_real_estate_strategy( |
|||
94000, 120000, 400000, total_years=5 |
|||
) |
|||
# Timeline includes year 0 through year 5 → 6 entries |
|||
assert len(result["timeline"]) == 6 |
|||
assert result["timeline"][0]["year"] == 0 |
|||
assert result["timeline"][-1]["year"] == 5 |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 6 — buy_interval > total_years means only one property purchased |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def test_single_property_no_rentals(): |
|||
result = simulate_real_estate_strategy( |
|||
94000, 120000, 400000, |
|||
buy_interval_years=20, # longer than simulation |
|||
total_years=10, |
|||
) |
|||
fp = result["final_picture"] |
|||
# Should have exactly 1 property (never triggers next buy) |
|||
assert fp["num_properties_owned"] == 1 |
|||
# No rental income since only one property (primary home, not rented) |
|||
assert fp["annual_rental_income"] == 0 |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 7 — net worth generally grows over time |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def test_net_worth_grows_over_time(): |
|||
result = simulate_real_estate_strategy( |
|||
94000, 120000, 400000, total_years=10 |
|||
) |
|||
timeline = result["timeline"] |
|||
# Net worth at year 10 should be higher than year 0 |
|||
assert timeline[-1]["total_net_worth"] > timeline[0]["total_net_worth"] |
|||
@ -0,0 +1,216 @@ |
|||
""" |
|||
Real Estate Strategy Simulator |
|||
|
|||
Simulates a multi-property buy-and-rent strategy over time using |
|||
user-provided assumptions. All rate parameters default to None — |
|||
users are encouraged to provide their own assumptions rather than |
|||
relying on generic defaults. |
|||
|
|||
The defaults (appreciation=4%, rent yield=8%, etc.) are starting |
|||
points for exploration only. They are not predictions. |
|||
""" |
|||
|
|||
from __future__ import annotations |
|||
|
|||
from typing import Optional |
|||
|
|||
|
|||
def simulate_real_estate_strategy( |
|||
initial_portfolio_value: float, |
|||
annual_income: float, |
|||
first_home_price: float, |
|||
down_payment_pct: float = 0.20, |
|||
buy_interval_years: int = 2, |
|||
total_years: int = 10, |
|||
annual_appreciation: Optional[float] = None, |
|||
annual_rent_yield: Optional[float] = None, |
|||
mortgage_rate: Optional[float] = None, |
|||
annual_market_return: Optional[float] = None, |
|||
) -> dict: |
|||
""" |
|||
Simulate buying a home every N years, renting previous ones. |
|||
|
|||
Parameters |
|||
---------- |
|||
initial_portfolio_value : float |
|||
Starting investment portfolio value (e.g. 94000) |
|||
annual_income : float |
|||
Annual gross income used to estimate savings contributions |
|||
first_home_price : float |
|||
Purchase price of the first property |
|||
down_payment_pct : float |
|||
Down payment fraction (default 0.20 = 20%) |
|||
buy_interval_years : int |
|||
How many years between each property purchase |
|||
total_years : int |
|||
How many years to run the simulation |
|||
annual_appreciation : float | None |
|||
Annual home value appreciation rate. None → uses 0.04 default. |
|||
annual_rent_yield : float | None |
|||
Annual gross rent as fraction of property value. None → uses 0.08. |
|||
mortgage_rate : float | None |
|||
Annual mortgage interest rate. None → uses 0.0695 (current avg). |
|||
annual_market_return : float | None |
|||
Annual return on investment portfolio. None → uses 0.07. |
|||
""" |
|||
|
|||
# ── User-provided assumptions with sensible defaults ────────────────────── |
|||
# Defaults are NOT predictions — they are starting points. |
|||
# Users should adjust these to match their expectations. |
|||
|
|||
appreciation = annual_appreciation if annual_appreciation is not None else 0.04 |
|||
rent_yield = annual_rent_yield if annual_rent_yield is not None else 0.08 |
|||
rate = mortgage_rate if mortgage_rate is not None else 0.0695 |
|||
market_return = annual_market_return if annual_market_return is not None else 0.07 |
|||
|
|||
# ── Mortgage helper ─────────────────────────────────────────────────────── |
|||
monthly_rate = rate / 12 |
|||
loan_term_months = 360 # 30-year fixed |
|||
|
|||
def monthly_payment(loan_amount: float) -> float: |
|||
if loan_amount <= 0 or monthly_rate == 0: |
|||
return 0.0 |
|||
return loan_amount * ( |
|||
monthly_rate * (1 + monthly_rate) ** loan_term_months |
|||
) / ((1 + monthly_rate) ** loan_term_months - 1) |
|||
|
|||
def remaining_balance(loan_amount: float, years_paid: int) -> float: |
|||
"""Outstanding mortgage balance after years_paid years.""" |
|||
if loan_amount <= 0 or monthly_rate == 0: |
|||
return 0.0 |
|||
n = years_paid * 12 |
|||
return loan_amount * ( |
|||
(1 + monthly_rate) ** loan_term_months |
|||
- (1 + monthly_rate) ** n |
|||
) / ((1 + monthly_rate) ** loan_term_months - 1) |
|||
|
|||
# ── Simulation ──────────────────────────────────────────────────────────── |
|||
portfolio = float(initial_portfolio_value) |
|||
properties: list[dict] = [] # {purchase_year, price, loan, is_rental} |
|||
timeline: list[dict] = [] |
|||
|
|||
# Estimate annual savings as ~20% of income (rough rule of thumb) |
|||
annual_savings = annual_income * 0.20 |
|||
|
|||
next_buy_year = 0 # first purchase at year 0 (start) |
|||
prop_num = 0 |
|||
|
|||
for year in range(total_years + 1): |
|||
# ── Buy a property this year? ───────────────────────────────────────── |
|||
if year == next_buy_year: |
|||
# Price grows with appreciation from first home price |
|||
price = first_home_price * ((1 + appreciation) ** year) |
|||
down = price * down_payment_pct |
|||
loan = price - down |
|||
|
|||
if portfolio >= down: |
|||
portfolio -= down |
|||
# Mark previous property as rental |
|||
if properties: |
|||
properties[-1]["is_rental"] = True |
|||
|
|||
properties.append({ |
|||
"purchase_year": year, |
|||
"price": price, |
|||
"loan": loan, |
|||
"is_rental": False, |
|||
}) |
|||
prop_num += 1 |
|||
next_buy_year = year + buy_interval_years |
|||
|
|||
# ── Grow portfolio ──────────────────────────────────────────────────── |
|||
portfolio = portfolio * (1 + market_return) + annual_savings |
|||
|
|||
# ── Compute current values ──────────────────────────────────────────── |
|||
total_re_equity = 0.0 |
|||
total_rental_income = 0.0 |
|||
total_mortgage_payments = 0.0 |
|||
|
|||
prop_snapshots = [] |
|||
for p in properties: |
|||
years_held = year - p["purchase_year"] |
|||
current_value = p["price"] * ((1 + appreciation) ** years_held) |
|||
bal = remaining_balance(p["loan"], years_held) |
|||
equity = max(0.0, current_value - bal) |
|||
total_re_equity += equity |
|||
|
|||
mpay = monthly_payment(p["loan"]) |
|||
annual_mpay = mpay * 12 |
|||
total_mortgage_payments += annual_mpay |
|||
|
|||
if p["is_rental"]: |
|||
gross_rent = current_value * rent_yield |
|||
net_rent = gross_rent * 0.65 # ~35% for expenses/vacancy |
|||
total_rental_income += net_rent |
|||
|
|||
prop_snapshots.append({ |
|||
"purchase_year": p["purchase_year"], |
|||
"current_value": round(current_value), |
|||
"mortgage_balance": round(bal), |
|||
"equity": round(equity), |
|||
"is_rental": p["is_rental"], |
|||
"annual_mortgage_payment": round(annual_mpay), |
|||
}) |
|||
|
|||
total_net_worth = portfolio + total_re_equity |
|||
|
|||
timeline.append({ |
|||
"year": year, |
|||
"portfolio_value": round(portfolio), |
|||
"total_real_estate_equity": round(total_re_equity), |
|||
"total_net_worth": round(total_net_worth), |
|||
"num_properties": len(properties), |
|||
"annual_rental_income": round(total_rental_income), |
|||
"annual_mortgage_payments": round(total_mortgage_payments), |
|||
"properties": prop_snapshots, |
|||
}) |
|||
|
|||
final = timeline[-1] |
|||
final_props = final["properties"] |
|||
|
|||
return { |
|||
"strategy": { |
|||
"buy_interval_years": buy_interval_years, |
|||
"total_years": total_years, |
|||
"properties_purchased": len(properties), |
|||
"down_payment_pct": f"{down_payment_pct * 100:.0f}%", |
|||
"assumptions": { |
|||
"annual_appreciation": f"{appreciation * 100:.1f}%", |
|||
"rent_yield": f"{rent_yield * 100:.1f}%", |
|||
"mortgage_rate": f"{rate * 100:.2f}%", |
|||
"market_return": f"{market_return * 100:.1f}%", |
|||
"user_provided": annual_appreciation is not None, |
|||
"note": ( |
|||
"These are YOUR assumptions — not market predictions. " |
|||
"Adjust them to match your expectations. " |
|||
"Real estate performance varies by location, timing, " |
|||
"and economic conditions." |
|||
), |
|||
}, |
|||
}, |
|||
"timeline": timeline, |
|||
"final_picture": { |
|||
"year": total_years, |
|||
"investment_portfolio": final["portfolio_value"], |
|||
"total_real_estate_equity": final["total_real_estate_equity"], |
|||
"total_net_worth": final["total_net_worth"], |
|||
"num_properties_owned": final["num_properties"], |
|||
"annual_rental_income": final["annual_rental_income"], |
|||
"properties": final_props, |
|||
}, |
|||
"how_to_adjust": ( |
|||
"Want to see a different scenario? Try asking: " |
|||
"'Run the same simulation but with 3% appreciation' " |
|||
"or 'What if rent yield is only 6%?' " |
|||
"or 'Show me a conservative scenario with 2% appreciation " |
|||
"and 5% market return.'" |
|||
), |
|||
"disclaimer": ( |
|||
"This projection uses the assumptions you provided. " |
|||
"It is a planning tool, not a prediction. " |
|||
"Real appreciation, rental rates, and investment returns " |
|||
"vary significantly and cannot be guaranteed. " |
|||
"Consult a licensed financial advisor before making " |
|||
"real estate investment decisions." |
|||
), |
|||
} |
|||
Loading…
Reference in new issue