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