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_per | per_inv | home_value | first_month_home_value | ppmt | ipmt | pmt | maintenance | insurance | property_taxes | ... | total_rent_assets | 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 |
---|
0 | 0 | 239 | 1.000000e+06 | 1.000000e+06 | 745.705185 | 4228.000000 | 4973.705185 | 416.666667 | 333.333333 | 666.666667 | ... | 0.0 | 209604.871852 | 0.000000e+00 | 1.025843e+06 | -0.000000e+00 | 871966.474075 | 0.000000e+00 | 8.719665e+05 | 0.000000e+00 | -8.719665e+05 |
---|
1 | 0 | 238 | 1.006667e+06 | 1.000000e+06 | 749.646237 | 4224.058948 | 4973.705185 | 416.666667 | 333.333333 | 666.666667 | ... | 0.0 | 3154.871852 | 0.000000e+00 | 1.533824e+04 | -0.000000e+00 | 13037.503844 | 0.000000e+00 | 8.850040e+05 | 0.000000e+00 | -8.850040e+05 |
---|
2 | 0 | 237 | 1.013378e+06 | 1.000000e+06 | 753.608117 | 4220.097068 | 4973.705185 | 416.666667 | 333.333333 | 666.666667 | ... | 0.0 | 3154.871852 | 0.000000e+00 | 1.523666e+04 | -0.000000e+00 | 12951.162759 | 0.000000e+00 | 8.979551e+05 | 0.000000e+00 | -8.979551e+05 |
---|
3 | 0 | 236 | 1.020134e+06 | 1.000000e+06 | 757.590936 | 4216.114249 | 4973.705185 | 416.666667 | 333.333333 | 666.666667 | ... | 0.0 | 3154.871852 | 0.000000e+00 | 1.513576e+04 | -0.000000e+00 | 12865.393470 | 0.000000e+00 | 9.108205e+05 | 0.000000e+00 | -9.108205e+05 |
---|
4 | 0 | 235 | 1.026935e+06 | 1.000000e+06 | 761.594804 | 4212.110381 | 4973.705185 | 416.666667 | 333.333333 | 666.666667 | ... | 0.0 | 3154.871852 | 0.000000e+00 | 1.503552e+04 | -0.000000e+00 | 12780.192188 | 0.000000e+00 | 9.236007e+05 | 0.000000e+00 | -9.236007e+05 |
---|
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
---|
235 | 19 | 4 | 4.765810e+06 | 4.549220e+06 | 2573.532960 | 2400.172225 | 4973.705185 | 1895.508239 | 1516.406591 | 3032.813182 | ... | 0.0 | 7596.642466 | 0.000000e+00 | 7.801254e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.971966e+06 | 0.000000e+00 | -2.971966e+06 |
---|
236 | 19 | 3 | 4.797582e+06 | 4.549220e+06 | 2587.134082 | 2386.571103 | 4973.705185 | 1895.508239 | 1516.406591 | 3032.813182 | ... | 0.0 | 7596.642466 | 0.000000e+00 | 7.749590e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.979562e+06 | 0.000000e+00 | -2.979562e+06 |
---|
237 | 19 | 2 | 4.829566e+06 | 4.549220e+06 | 2600.807086 | 2372.898100 | 4973.705185 | 1895.508239 | 1516.406591 | 3032.813182 | ... | 0.0 | 7596.642466 | 0.000000e+00 | 7.698269e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.987159e+06 | 0.000000e+00 | -2.987159e+06 |
---|
238 | 19 | 1 | 4.861763e+06 | 4.549220e+06 | 2614.552351 | 2359.152834 | 4973.705185 | 1895.508239 | 1516.406591 | 3032.813182 | ... | 0.0 | 7596.642466 | 0.000000e+00 | 7.647287e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.994755e+06 | 0.000000e+00 | -2.994755e+06 |
---|
239 | 19 | 0 | 4.894175e+06 | 4.549220e+06 | 2628.370260 | 2345.334925 | 4973.705185 | 1895.508239 | 1516.406591 | 3032.813182 | ... | 3550.0 | 0.000000 | 4.148234e+06 | -0.000000e+00 | 4.148234e+06 | 0.000000 | 4.148234e+06 | 2.994755e+06 | 4.148234e+06 | 1.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_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 |
---|
0 | 209604.871852 | 0.000000e+00 | 1.025843e+06 | -0.000000e+00 | 871966.474075 | 0.000000e+00 | 8.719665e+05 | 0.000000e+00 | -8.719665e+05 |
---|
1 | 3154.871852 | 0.000000e+00 | 1.533824e+04 | -0.000000e+00 | 13037.503844 | 0.000000e+00 | 8.850040e+05 | 0.000000e+00 | -8.850040e+05 |
---|
2 | 3154.871852 | 0.000000e+00 | 1.523666e+04 | -0.000000e+00 | 12951.162759 | 0.000000e+00 | 8.979551e+05 | 0.000000e+00 | -8.979551e+05 |
---|
3 | 3154.871852 | 0.000000e+00 | 1.513576e+04 | -0.000000e+00 | 12865.393470 | 0.000000e+00 | 9.108205e+05 | 0.000000e+00 | -9.108205e+05 |
---|
4 | 3154.871852 | 0.000000e+00 | 1.503552e+04 | -0.000000e+00 | 12780.192188 | 0.000000e+00 | 9.236007e+05 | 0.000000e+00 | -9.236007e+05 |
---|
... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
---|
235 | 7596.642466 | 0.000000e+00 | 7.801254e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.971966e+06 | 0.000000e+00 | -2.971966e+06 |
---|
236 | 7596.642466 | 0.000000e+00 | 7.749590e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.979562e+06 | 0.000000e+00 | -2.979562e+06 |
---|
237 | 7596.642466 | 0.000000e+00 | 7.698269e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.987159e+06 | 0.000000e+00 | -2.987159e+06 |
---|
238 | 7596.642466 | 0.000000e+00 | 7.647287e+03 | -0.000000e+00 | 7596.642466 | 0.000000e+00 | 2.994755e+06 | 0.000000e+00 | -2.994755e+06 |
---|
239 | 0.000000 | 4.148234e+06 | -0.000000e+00 | 4.148234e+06 | 0.000000 | 4.148234e+06 | 2.994755e+06 | 4.148234e+06 | 1.153479e+06 |
---|
240 rows × 9 columns
Giving a final opportunity of:
1
| print(df.buy_vs_rent.iloc[-1])
|
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,
)
|
| fun | status | success | message | x | nfev | nit |
---|
1 | 23103.309099 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
2 | 127799.755015 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
3 | 166019.824956 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
4 | 195736.457907 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
5 | 226646.980796 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
6 | 258721.602081 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
7 | 291917.176596 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
8 | 326175.193242 | 0 | True | Solution found. | 0.999994 | 25 | 25 |
---|
9 | 379172.628531 | 0 | True | Solution found. | 0.199254 | 25 | 25 |
---|
10 | 438689.434209 | 0 | True | Solution found. | 0.199254 | 25 | 25 |
---|
11 | 500626.654147 | 0 | True | Solution found. | 0.199257 | 26 | 26 |
---|
12 | 564957.544662 | 0 | True | Solution found. | 0.199256 | 26 | 26 |
---|
13 | 631632.981999 | 0 | True | Solution found. | 0.199257 | 26 | 26 |
---|
14 | 700581.034804 | 0 | True | Solution found. | 0.199256 | 26 | 26 |
---|
15 | 771700.204818 | 0 | True | Solution found. | 0.199255 | 25 | 25 |
---|
16 | 844857.318232 | 0 | True | Solution found. | 0.199256 | 26 | 26 |
---|
17 | 919884.791461 | 0 | True | Solution found. | 0.199257 | 26 | 26 |
---|
18 | 996573.880403 | 0 | True | Solution found. | 0.199255 | 26 | 26 |
---|
19 | 1074664.208949 | 0 | True | Solution found. | 0.199254 | 26 | 26 |
---|
20 | 1153841.877013 | 0 | True | Solution found. | 0.199255 | 25 | 25 |
---|
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,
)
|
| fun | status | success | message | x | nfev | nit |
---|
1 | 8762.269232 | 0 | True | Solution found. | 0.099993 | 20 | 20 |
---|
2 | 2.396666 | 0 | True | Solution found. | 0.06651 | 17 | 17 |
---|
3 | 0.627588 | 0 | True | Solution found. | 0.057847 | 15 | 15 |
---|
4 | 2.871965 | 0 | True | Solution found. | 0.054638 | 13 | 13 |
---|
5 | 7.840696 | 0 | True | Solution found. | 0.053182 | 15 | 15 |
---|
6 | 0.727825 | 0 | True | Solution found. | 0.052594 | 14 | 14 |
---|
7 | 14.16125 | 0 | True | Solution found. | 0.052506 | 13 | 13 |
---|
8 | 3.670759 | 0 | True | Solution found. | 0.052725 | 14 | 14 |
---|
9 | 5.841816 | 0 | True | Solution found. | 0.053153 | 14 | 14 |
---|
10 | 5.531079 | 0 | True | Solution found. | 0.053726 | 13 | 13 |
---|
11 | 3.957799 | 0 | True | Solution found. | 0.054402 | 15 | 15 |
---|
12 | 20.046186 | 0 | True | Solution found. | 0.055157 | 14 | 14 |
---|
13 | 34.684645 | 0 | True | Solution found. | 0.055966 | 19 | 19 |
---|
14 | 26.619315 | 0 | True | Solution found. | 0.056816 | 15 | 15 |
---|
15 | 40.044397 | 0 | True | Solution found. | 0.057694 | 14 | 14 |
---|
16 | 0.392994 | 0 | True | Solution found. | 0.058597 | 16 | 16 |
---|
17 | 9.102877 | 0 | True | Solution found. | 0.059512 | 16 | 16 |
---|
18 | 55.321394 | 0 | True | Solution found. | 0.060433 | 14 | 14 |
---|
19 | 19.12799 | 0 | True | Solution found. | 0.06136 | 14 | 14 |
---|
20 | 70.040728 | 0 | True | Solution found. | 0.062284 | 15 | 15 |
---|
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
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",
]
)
|