Post

Rent vs. Buy in Numpy

Should you rent or buy your next home? NumPy rewrite of this New York Times Calculator. You can find notebook here which uses NumPy and numpy-financial.

1
2
3
4
5
6
7
8
9
10
11
12
import pandas as pd
import numpy as np
import plotly.express as px
from scipy.optimize import minimize_scalar

from src.rent_vs_buy import (
    RentVsBuy,
    rent_vs_buy_breakeven_objective_closure,
    render_plotly_html,
    optimization_per_year,
    breakeven_per_year,
)

Setup

We can use RentVsBuy’s calculate function to simulate a financial scenario:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# We will reuse these kwargs later on.
kwargs = dict(
    home_price=1_000_000,  # Value of the home
    financed_fees=0,  # Any fees that are financed (i.e. VA Loan Fee)
    years=20,  # Compute earnings over X-year period
    mortgage_rate=0.06342,  # Mortgage rate
    downpayment=0.2,  # Downpayment amount
    pmi=0.005,  # PMI expressed as a percentage of the total loan value
    length_of_mortgage=30,  # 30-year fixed mortgage
    home_price_growth_rate=0.08,  # Annual appreciation of the home
    rent_growth_rate=0.01,  # How much rent increases annually
    investment_return_rate=0.08,  # How much can we make in the market annually?
    investment_tax_rate=0.15,  # Long term capital gains tax
    inflation_rate=0.02,  # How much does inflation go up each year?
    filing_jointly=True,  # Tax filing status
    property_tax_rate=0.008,  # Annual tax rate on the home
    marginal_tax_rate=0.02,  # Tax bracket based on income
    costs_of_buying_home=0.01,  # How much do we pay at closing to buy?
    costs_of_selling_home=0.06,  # Seller fee
    maintenance_rate=0.005,  # How much we put away each year for maintenance (roofs, appliances, etc.)
    home_owners_insurance_rate=0.004,  # Insurance on the home.
    monthly_utilities=0,  # Utilities for the home (usually a wash with renting utilities)
    monthly_common_fees=350,  # HOA fees, etc.
    monthly_rent=3550,  # How much is the current rent?
    security_deposit=1,  # Proportion of monthly rent paid in the first month
    brokers_fee=0.00,  # Brokers fee (if applicable)
    renters_insurance_rate=0.01,  # Rental insurance
)
# Create our RentVsBuy calculator.
rent_vs_buy = RentVsBuy().calculate(**kwargs)
df = rent_vs_buy.df
df
annual_perper_invhome_valuefirst_month_home_valueppmtipmtpmtmaintenanceinsuranceproperty_taxes...total_rent_assetshome_opportunity_costrental_opportunity_costhome_opportunity_cost_fvrental_opportunity_cost_fvhome_opportunity_cost_fv_post_taxrental_opportunity_cost_fv_post_taxhome_cumulative_opportunityrental_cumulative_opportunitybuy_vs_rent
002391.000000e+061.000000e+06745.7051854228.0000004973.705185416.666667333.333333666.666667...0.0209604.8718520.000000e+001.025843e+06-0.000000e+00871966.4740750.000000e+008.719665e+050.000000e+00-8.719665e+05
102381.006667e+061.000000e+06749.6462374224.0589484973.705185416.666667333.333333666.666667...0.03154.8718520.000000e+001.533824e+04-0.000000e+0013037.5038440.000000e+008.850040e+050.000000e+00-8.850040e+05
202371.013378e+061.000000e+06753.6081174220.0970684973.705185416.666667333.333333666.666667...0.03154.8718520.000000e+001.523666e+04-0.000000e+0012951.1627590.000000e+008.979551e+050.000000e+00-8.979551e+05
302361.020134e+061.000000e+06757.5909364216.1142494973.705185416.666667333.333333666.666667...0.03154.8718520.000000e+001.513576e+04-0.000000e+0012865.3934700.000000e+009.108205e+050.000000e+00-9.108205e+05
402351.026935e+061.000000e+06761.5948044212.1103814973.705185416.666667333.333333666.666667...0.03154.8718520.000000e+001.503552e+04-0.000000e+0012780.1921880.000000e+009.236007e+050.000000e+00-9.236007e+05
..................................................................
2351944.765810e+064.549220e+062573.5329602400.1722254973.7051851895.5082391516.4065913032.813182...0.07596.6424660.000000e+007.801254e+03-0.000000e+007596.6424660.000000e+002.971966e+060.000000e+00-2.971966e+06
2361934.797582e+064.549220e+062587.1340822386.5711034973.7051851895.5082391516.4065913032.813182...0.07596.6424660.000000e+007.749590e+03-0.000000e+007596.6424660.000000e+002.979562e+060.000000e+00-2.979562e+06
2371924.829566e+064.549220e+062600.8070862372.8981004973.7051851895.5082391516.4065913032.813182...0.07596.6424660.000000e+007.698269e+03-0.000000e+007596.6424660.000000e+002.987159e+060.000000e+00-2.987159e+06
2381914.861763e+064.549220e+062614.5523512359.1528344973.7051851895.5082391516.4065913032.813182...0.07596.6424660.000000e+007.647287e+03-0.000000e+007596.6424660.000000e+002.994755e+060.000000e+00-2.994755e+06
2391904.894175e+064.549220e+062628.3702602345.3349254973.7051851895.5082391516.4065913032.813182...3550.00.0000004.148234e+06-0.000000e+004.148234e+060.0000004.148234e+062.994755e+064.148234e+061.153479e+06

240 rows × 35 columns

Calculations

High level overview

First, model the cash flows over the life of the home. These cashflows should incorporate any inflation or appreciation that occurs with time. For this we need:

  • Value of the home
  • Liabilities of the home
  • Assets the home
  • Liabilities of renting
  • Assets of renting

Next, we can calculate the opportunity cost of either renting or buying. This is the difference in money (i.e. $8000 monthly house payment - $3000 monthly rent payment) that we can invest for the remaining time of the home, appreciated at market rates.

Below, we show the output of these various calculations for the given scenario.

Home Value

How much will the home be worth when we sell it?

1
2
3
4
5
6
7
8
9
10
render_plotly_html(
    px.line(
        pd.DataFrame(
            {
                "Home Value": df.home_value,
                "Initial Home Price": df.home_value[0],
            }
        )
    )
)

House Liabilities

What will we need to pay to upkeep the home?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
render_plotly_html(
    px.line(
        pd.DataFrame(
            {
                "Monthly Fees": df.monthly_common_fees,
                "Insurance": df.insurance,
                "Property Taxes": df.property_taxes,
                "Maintenance": df.maintenance,
                "Closing Costs": df.buying_closing_costs,
                "Mortgage Principal Payment": df.ppmt,
                "Mortgage Interest Payment": df.ipmt,
                "Total Mortgage Payment": df.pmt,
                "Utilities": df.home_monthly_utilities,
                "Downpayment": df.down_fee,
                "PMI": df.pmi,
                "Sellers Fee": df.sellers_fee,
                "Loan Payoff": df.loan_payoff,
            }
        ),
        log_y=True,
        markers=True,
    )
)

House Assets

What money will the home bring in? This includes a tax credit for paying interest on a house (limited to 750K over the lifetime of the loan) and the sale of the house in the last month (shown on the log-scale).

1
render_plotly_html(px.line(pd.DataFrame({"Total Home Assets": df.total_home_assets}), log_y=True, markers=True))

Rent Liabilities

What will renting cost us? Mostly, this is the rent payment. But also includes some other expenses that do not occur when buying a home.

1
2
3
4
5
6
7
8
9
10
11
12
13
render_plotly_html(
    px.line(
        pd.DataFrame(
            {
                "Rent Payment": df.rent,
                "Renters Insurance": df.renters_insurance,
                "Security Deposit": df.security_deposit_cost,
                "Brokers Fee": df.brokers_fee_cost,
            }
        ),
        markers=True,
    )
)

Rent Assets

The only positive cash flow in renting is getting back the security deposit in the last month.

1
2
3
4
5
6
render_plotly_html(
    px.line(
        pd.DataFrame({"Rent Assets": df.total_rent_assets}),
        markers=True,
    )
)

Opportunity Costs

If we didn’t buy the house, how much could we earn in the market after we pay the rent?

1
opportunity_cost = (total_home_liability - total_home_assets) - (total_rent_liability - total_rent_assets)

We take the future value of each month’s cash flow (to the last month’s period) and then apply a capital gains tax penalty for selling the asset at the end of the period. Then we compute:

1
buy_vs_rent = rental_cumulative_opportunity - home_cumulative_opportunity

In the last month to see the total advantage of owning a home vs. renting.

1
2
3
4
5
6
7
8
9
10
11
12
13
df[
    [
        "home_opportunity_cost",
        "rental_opportunity_cost",
        "home_opportunity_cost_fv",
        "rental_opportunity_cost_fv",
        "home_opportunity_cost_fv_post_tax",
        "rental_opportunity_cost_fv_post_tax",
        "home_cumulative_opportunity",
        "rental_cumulative_opportunity",
        "buy_vs_rent",
    ]
]
home_opportunity_costrental_opportunity_costhome_opportunity_cost_fvrental_opportunity_cost_fvhome_opportunity_cost_fv_post_taxrental_opportunity_cost_fv_post_taxhome_cumulative_opportunityrental_cumulative_opportunitybuy_vs_rent
0209604.8718520.000000e+001.025843e+06-0.000000e+00871966.4740750.000000e+008.719665e+050.000000e+00-8.719665e+05
13154.8718520.000000e+001.533824e+04-0.000000e+0013037.5038440.000000e+008.850040e+050.000000e+00-8.850040e+05
23154.8718520.000000e+001.523666e+04-0.000000e+0012951.1627590.000000e+008.979551e+050.000000e+00-8.979551e+05
33154.8718520.000000e+001.513576e+04-0.000000e+0012865.3934700.000000e+009.108205e+050.000000e+00-9.108205e+05
43154.8718520.000000e+001.503552e+04-0.000000e+0012780.1921880.000000e+009.236007e+050.000000e+00-9.236007e+05
..............................
2357596.6424660.000000e+007.801254e+03-0.000000e+007596.6424660.000000e+002.971966e+060.000000e+00-2.971966e+06
2367596.6424660.000000e+007.749590e+03-0.000000e+007596.6424660.000000e+002.979562e+060.000000e+00-2.979562e+06
2377596.6424660.000000e+007.698269e+03-0.000000e+007596.6424660.000000e+002.987159e+060.000000e+00-2.987159e+06
2387596.6424660.000000e+007.647287e+03-0.000000e+007596.6424660.000000e+002.994755e+060.000000e+00-2.994755e+06
2390.0000004.148234e+06-0.000000e+004.148234e+060.0000004.148234e+062.994755e+064.148234e+061.153479e+06

240 rows × 9 columns

Giving a final opportunity of:

1
print(df.buy_vs_rent.iloc[-1])
1
1153478.706195381

Optimize

In addition to visualizing the cost curves, we can also use scipy.optimize.minimize_scalar to answer questions. If we only wanted to stay in the house for 5 years, what would be the optimal mortgage rate to breakeven on our investment?

1
2
optimize_kwargs = kwargs.copy()
optimize_kwargs["years"] = 5
1
2
3
4
5
6
minimize_scalar(
    fun=rent_vs_buy_breakeven_objective_closure(
        scalar="mortgage_rate", **optimize_kwargs
    ),
    bounds=[0.0001, 0.1],
)
1
2
3
4
5
6
7
 message: Solution found.
 success: True
  status: 0
     fun: 8689.864875817322
       x: 0.09999339591456094
     nit: 20
    nfev: 20

We can also maximize the house advantage by optimizing the downpayment for a certain amount of years:

1
2
3
4
5
6
7
optimization_per_year(
    scalar="downpayment",
    bounds=[0.0, 1.0],
    start_year=1,
    end_year=21,
    kwargs=optimize_kwargs,
)
funstatussuccessmessagexnfevnit
123103.3090990TrueSolution found.0.9999942525
2127799.7550150TrueSolution found.0.9999942525
3166019.8249560TrueSolution found.0.9999942525
4195736.4579070TrueSolution found.0.9999942525
5226646.9807960TrueSolution found.0.9999942525
6258721.6020810TrueSolution found.0.9999942525
7291917.1765960TrueSolution found.0.9999942525
8326175.1932420TrueSolution found.0.9999942525
9379172.6285310TrueSolution found.0.1992542525
10438689.4342090TrueSolution found.0.1992542525
11500626.6541470TrueSolution found.0.1992572626
12564957.5446620TrueSolution found.0.1992562626
13631632.9819990TrueSolution found.0.1992572626
14700581.0348040TrueSolution found.0.1992562626
15771700.2048180TrueSolution found.0.1992552525
16844857.3182320TrueSolution found.0.1992562626
17919884.7914610TrueSolution found.0.1992572626
18996573.8804030TrueSolution found.0.1992552626
191074664.2089490TrueSolution found.0.1992542626
201153841.8770130TrueSolution found.0.1992552525

Or, we can see what home price growth rate we would need to breakeven with renting per year:

1
2
3
4
5
6
7
breakeven_per_year(
    scalar="home_price_growth_rate",
    bounds=[0.0001, 0.1],
    start_year=1,
    end_year=21,
    kwargs=kwargs,
)
funstatussuccessmessagexnfevnit
18762.2692320TrueSolution found.0.0999932020
22.3966660TrueSolution found.0.066511717
30.6275880TrueSolution found.0.0578471515
42.8719650TrueSolution found.0.0546381313
57.8406960TrueSolution found.0.0531821515
60.7278250TrueSolution found.0.0525941414
714.161250TrueSolution found.0.0525061313
83.6707590TrueSolution found.0.0527251414
95.8418160TrueSolution found.0.0531531414
105.5310790TrueSolution found.0.0537261313
113.9577990TrueSolution found.0.0544021515
1220.0461860TrueSolution found.0.0551571414
1334.6846450TrueSolution found.0.0559661919
1426.6193150TrueSolution found.0.0568161515
1540.0443970TrueSolution found.0.0576941414
160.3929940TrueSolution found.0.0585971616
179.1028770TrueSolution found.0.0595121616
1855.3213940TrueSolution found.0.0604331414
1919.127990TrueSolution found.0.061361414
2070.0407280TrueSolution found.0.0622841515

Vectorize

Since numpy-financial is a vectorized library, we can actually see the breakdown for a range of values. For example, we can visualize a 2-d surface of the total opportunity.

home_price and downpayment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_x, _y = np.linspace(100_000, 2_000_000, 100), np.linspace(0.00, 1.0, 100)
x, y = np.meshgrid(_x, _y, indexing="ij")
x = x.flatten()
y = y.flatten()
surface_kwargs = kwargs.copy()
surface_kwargs.update({"home_price": x, "downpayment": y})
render_plotly_html(
    px.imshow(
        RentVsBuy().calculate(**surface_kwargs).value.reshape((100, 100)).T,
        aspect="auto",
        labels=dict(x="Home Price", y="Downpayment", color="Opportunity"),
        x=_x,
        y=_y,
        title="Opportunity Surface",
    )
)

home_price_growth_rate and investment_return_rate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_x, _y = np.linspace(0.02, 0.1, 100), np.linspace(0.02, 0.1, 100)
x, y = np.meshgrid(_x, _y, indexing="ij")
x = x.flatten()
y = y.flatten()
surface_kwargs = kwargs.copy()
surface_kwargs.update({"home_price_growth_rate": x, "investment_return_rate": y})
render_plotly_html(
    px.imshow(
        RentVsBuy().calculate(**surface_kwargs).value.reshape((100, 100)).T,
        aspect="auto",
        labels=dict(x="Home Price Growth", y="Investment Growth", color="Opportunity"),
        x=_x,
        y=_y,
        title="Opportunity Surface",
    )
)

Simulate

We can also simulate market conditions.

1
n = 10_000
1
2
simulated_investment_rate = np.random.beta(10, 5, n) * (0.1 - 0.03) + 0.03
render_plotly_html(px.histogram(simulated_investment_rate, title="Investment Rate"))
1
2
simulated_home_growth_rates = np.random.beta(10, 5, n) * (0.08 - 0.03) + 0.03
render_plotly_html(px.histogram(simulated_home_growth_rates, title="Home Growth Rate"))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
random_kwargs = kwargs.copy()
random_kwargs.update(
    {
        "investment_return_rate": simulated_investment_rate,
        "home_price_growth_rate": simulated_home_growth_rates,
    }
)
simulated_break_even = RentVsBuy().calculate(**random_kwargs).value
render_plotly_html(px.histogram(simulated_break_even, title="Simulated Opportunity"))
# Print the p05, mean, and p95 of the distribution.
print(
    "p05",
    np.quantile(simulated_break_even, 0.05),
    "\nMean",
    simulated_break_even.mean(),
    "\np95",
    np.quantile(simulated_break_even, 0.95),
)
1
2
3
p05 -537973.5755449401 
Mean 188798.3910280675 
p95 926086.1889873659
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# A little magic to automatically write my blog :)
import subprocess

subprocess.run(
    [
        "jupyter",
        "nbconvert",
        "--to",
        "markdown",
        "--output",
        "~/Documents/jakee417.github.io/_includes/markdown/rent_vs_buy_blog_post.md",
        "rent_vs_buy_blog_post.ipynb",
    ]
)
This post is licensed under CC BY 4.0 by the author.