Skip to content

openavmkit.income

calculate_cap_rate_growth

calculate_cap_rate_growth(sale_price_growth, noi_growth)

Calculate the capitalization rate given the annual percentage changes in sale price ($/sqft) and net operating income (NOI).

Given NOI = Sale Price * Cap Rate, the cap rate is NOI / Sale Price. Approximating the percentage change (assuming small changes):

ΔCap Rate ≈ ΔNOI - ΔSale Price.

Parameters:

Name Type Description Default
sale_price_growth ndarray

Annual percentage changes in sale price as decimals. (Example: [0.01, 0.01, 0.015, 0.02, 0.01] for 1%, 1%, 1.5%, 2%, 1%)

required
noi_growth ndarray

Annual percentage changes in NOI as decimals. (Example: [0.01, 0.005, 0.0, -0.0025, 0.01])

required

Returns:

Type Description
np.ndarray :

Capitalization rate as decimals

Source code in openavmkit/income.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def calculate_cap_rate_growth(
    sale_price_growth: np.ndarray, noi_growth: np.ndarray
) -> np.ndarray:
    """Calculate the capitalization rate given the annual percentage changes in sale price
    ($/sqft) and net operating income (NOI).

    Given `NOI = Sale Price * Cap Rate`, the cap rate is `NOI / Sale Price`.
    Approximating the percentage change (assuming small changes):

    ```
    ΔCap Rate ≈ ΔNOI - ΔSale Price.
    ```

    Parameters
    ----------
    sale_price_growth : np.ndarray
        Annual percentage changes in sale price as decimals. (Example: [0.01, 0.01, 0.015, 0.02, 0.01] for 1%, 1%, 1.5%, 2%, 1%)
    noi_growth : np.ndarray
        Annual percentage changes in NOI as decimals. (Example: [0.01, 0.005, 0.0, -0.0025, 0.01])

    Returns
    -------
    np.ndarray :
        Capitalization rate as decimals
    """
    if len(sale_price_growth) != len(noi_growth):
        raise ValueError("Input arrays must have the same length.")

    cap_rates = []
    for s_growth, n_growth in zip(sale_price_growth, noi_growth):
        cap_rate = (n_growth + 1) / (1 + s_growth) - 1
        cap_rates.append(cap_rate)

    return np.array(cap_rates)

calculate_noi

calculate_noi(price, cap_rate)

Calculate the Net Operating Income (NOI) given a property price and cap rate.

Source code in openavmkit/income.py
6
7
8
def calculate_noi(price: float, cap_rate: float) -> float:
    """Calculate the Net Operating Income (NOI) given a property price and cap rate."""
    return price * cap_rate

calculate_noi_growth

calculate_noi_growth(sale_price_growth, cap_rate_growth)

Calculate the annual percentage change in NOI given the annual percentage changes in sale price ($/sqft) and cap rate.

Given NOI = Sale Price * Cap Rate, the compounded growth in NOI is: (1 + ΔSale Price) * (1 + ΔCap Rate) - 1.

Parameters:

Name Type Description Default
sale_price_growth ndarray

Annual percentage changes in sale price as decimals. (Example: [0.01, 0.01, 0.015, 0.02, 0.01] for 1%, 1%, 1.5%, 2%, 1%)

required
cap_rate_growth ndarray

Annual percentage changes in cap rate as decimals. (Example: [0.01, 0.005, 0.0, -0.0025, 0.01])

required

Returns:

Type Description
np.ndarray :

Annual percentage changes in NOI as decimals.

Source code in openavmkit/income.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def calculate_noi_growth(
    sale_price_growth: np.ndarray, cap_rate_growth: np.ndarray
) -> np.ndarray:
    """Calculate the annual percentage change in NOI given the annual percentage changes
    in sale price ($/sqft) and cap rate.

    Given `NOI = Sale Price * Cap Rate`, the compounded growth in NOI is:
    `(1 + ΔSale Price) * (1 + ΔCap Rate) - 1`.


    Parameters
    ----------
    sale_price_growth : np.ndarray
        Annual percentage changes in sale price as decimals. (Example: [0.01, 0.01, 0.015, 0.02, 0.01] for 1%, 1%, 1.5%, 2%, 1%)
    cap_rate_growth : np.ndarray
        Annual percentage changes in cap rate as decimals. (Example: [0.01, 0.005, 0.0, -0.0025, 0.01])

    Returns
    -------
    np.ndarray :
        Annual percentage changes in NOI as decimals.

    """
    if len(sale_price_growth) != len(cap_rate_growth):
        raise ValueError("Input arrays must have the same length.")

    noi_growth = []
    for s_growth, c_growth in zip(sale_price_growth, cap_rate_growth):
        growth = (1 + s_growth) * (1 + c_growth) - 1
        noi_growth.append(growth)

    return np.array(noi_growth)

derive_irr

derive_irr(entry_price, exit_price, entry_cap_rate, noi_growth, holding_period)

Calculate the implied IRR given:

  • entry_price: Purchase price of the property.
  • exit_price: Observed sale price (terminal cash flow) at the end of the holding period.
  • entry_cap_rate: Entry cap rate (used to derive the initial NOI).
  • noi_growth: Annual growth rate of NOI (decimal form, e.g., 0.03 for 3%).
  • holding_period: Holding period in years.

The model assumes:

  • NOI₀ = entry_price * entry_cap_rate
  • IRRM = (1+IRR)
  • NOIM = (1+noi_growth)

And the DCF equation:

NPV = ∑ₜ₌₁ᴴ [NOI₀ * (1 + noi_growth)ᵗ / (1 + IRR)ᵗ] + [sale_price / (1 + IRR)ᴴ] - entry_price = 0 NPV = ∑ₜ₌₁ᴴ [NOI₀ * NOIMᵗ/IRRMᵗ] + [exit_price/IRRMᴴ] - entry_price = 0

Parameters:

Name Type Description Default
entry_price float

The purchase price of the property.

required
exit_price float

The observed sale price at the end of the holding period.

required
entry_cap_rate float

The entry cap rate as a decimal (e.g., 0.06 for 6%).

required
noi_growth float

The expected annual growth rate of NOI as a decimal (e.g., 0.03 for 3%).

required
holding_period int

The holding period in years.

required

Returns:

Type Description
The IRR (as a decimal).
Source code in openavmkit/income.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def derive_irr(
    entry_price: float,
    exit_price: float,
    entry_cap_rate: float,
    noi_growth: float,
    holding_period: int,
) -> float:
    """Calculate the implied IRR given:

      - **entry_price**: Purchase price of the property.
      - **exit_price**: Observed sale price (terminal cash flow) at the end of the holding period.
      - **entry_cap_rate**: Entry cap rate (used to derive the initial NOI).
      - **noi_growth**: Annual growth rate of NOI (decimal form, e.g., 0.03 for 3%).
      - **holding_period**: Holding period in years.

    The model assumes:

      - NOI₀ = entry_price * entry_cap_rate
      - IRRM = (1+IRR)
      - NOIM = (1+noi_growth)

      And the DCF equation:

      ```
        NPV = ∑ₜ₌₁ᴴ [NOI₀ * (1 + noi_growth)ᵗ / (1 + IRR)ᵗ] + [sale_price / (1 + IRR)ᴴ] - entry_price = 0
        NPV = ∑ₜ₌₁ᴴ [NOI₀ * NOIMᵗ/IRRMᵗ] + [exit_price/IRRMᴴ] - entry_price = 0
      ```

    Parameters
    ----------
    entry_price : float
        The purchase price of the property.
    exit_price : float
        The observed sale price at the end of the holding period.
    entry_cap_rate : float
        The entry cap rate as a decimal (e.g., 0.06 for 6%).
    noi_growth : float
        The expected annual growth rate of NOI as a decimal (e.g., 0.03 for 3%).
    holding_period : int
        The holding period in years.

    Returns
    -------
    The IRR (as a decimal).
    """
    # Derive the initial NOI from the entry cap rate.
    entry_noi = entry_price * entry_cap_rate

    def dcf_equation(IRR: float) -> float:
        # Sum the present value of the NOI cash flows for each year.

        pv_noi = sum(
            [
                entry_noi * (1 + noi_growth) ** t / (1 + IRR) ** t
                for t in range(1, holding_period + 1)
            ]
        )
        # Discount the observed sale price to present value.
        pv_sale = exit_price / (1 + IRR) ** holding_period
        # The NPV should equal zero for the correct IRR.
        return pv_noi + pv_sale - entry_price

    # Use a numerical solver to find the IRR that zeros the equation.
    # We search in a reasonable range for IRR (here between -0.99 and 1.0, i.e. -99% to 100%).
    return brentq(dcf_equation, -0.99, 1.0, full_output=False)

derive_irr_df

derive_irr_df(df, hist_cap_rates, hist_noi_growths)

Given a DataFrame of paired sales with columns:

  • "key"
  • "entry_price"
  • "exit_price"
  • "entry_date"
  • "exit_date"

And historical dictionaries:

  • "hist_cap_rates" : {year -> entry cap rate}
  • "hist_noi_growths" : {year -> NOI growth rate}

This function computes for each row:

  • holding_period (in years, float)
  • entry_year (from entry_date)
  • implied_IRR (using the DCF method)

Parameters:

Name Type Description Default
df DataFrame

A DataFrame of paired sales containing the columns ["key", "entry_price", "exit_price", "entry_date", "exit_date"]

required
hist_cap_rates dict

A Dictionary whose keys are years and whose values are entry cap rates

required
hist_noi_growths dict

A Dictionary whose keys are years and whose values are NOI growth rates

required

Returns:

Type Description
pd.DataFrame:

A new DataFrame that includes the original columns plus these computed fields.

Source code in openavmkit/income.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def derive_irr_df(
    df: pd.DataFrame, hist_cap_rates: dict, hist_noi_growths: dict
) -> pd.DataFrame:
    """Given a DataFrame of paired sales with columns:

      - "key"
      - "entry_price"
      - "exit_price"
      - "entry_date"
      - "exit_date"

    And historical dictionaries:

      - "hist_cap_rates" : {year -> entry cap rate}
      - "hist_noi_growths" : {year -> NOI growth rate}

    This function computes for each row:

      - holding_period (in years, float)
      - entry_year (from entry_date)
      - implied_IRR (using the DCF method)

    Parameters
    ----------
    df : pd.DataFrame
        A DataFrame of paired sales containing the columns ["key", "entry_price", "exit_price", "entry_date", "exit_date"]
    hist_cap_rates: dict
        A Dictionary whose keys are years and whose values are entry cap rates
    hist_noi_growths: dict
        A Dictionary whose keys are years and whose values are NOI growth rates

    Returns
    -------
    pd.DataFrame:
        A new DataFrame that includes the original columns plus these computed fields.
    """

    def compute_row(row):
        # Convert dates to Timestamp
        entry_date = pd.to_datetime(row["entry_date"])
        exit_date = pd.to_datetime(row["exit_date"])
        # Compute holding period in years (as float)
        holding_period_years = (exit_date - entry_date).days / 365.25
        # For DCF, use an integer number of years
        holding_period_int = int(round(holding_period_years))
        entry_year = entry_date.year

        # Look up historical parameters for the entry year.
        entry_cap_rate = hist_cap_rates.get(entry_year)
        noi_growth = hist_noi_growths.get(entry_year)
        if entry_cap_rate is None or noi_growth is None or holding_period_int < 1:
            return pd.Series(
                {
                    "holding_period": holding_period_int,
                    "implied_IRR": None,
                    "entry_year": entry_year,
                    "entry_cap_rate": entry_cap_rate,
                }
            )

        try:
            implied_irr = derive_irr(
                row["entry_price"],
                row["exit_price"],
                entry_cap_rate,
                noi_growth,
                holding_period_int,
            )
        except Exception:
            implied_irr = None

        return pd.Series(
            {
                "holding_period": holding_period_int,
                "implied_irr": implied_irr,
                "entry_year": entry_year,
                "entry_cap_rate": entry_cap_rate,
                "entry_noi": row["entry_price"] * entry_cap_rate,
            }
        )

    computed = df.apply(compute_row, axis=1)
    # Concatenate the computed columns with the original DataFrame.
    return pd.concat([df, computed], axis=1)

derive_prices

derive_prices(target_irr, exit_cap_rate, entry_noi, noi_growth, holding_period)

Calculate the entry and exit prices based on a DCF model given:

  • target_irr: The target internal rate of return (IRR) (e.g., 0.10 for 10%)
  • exit_cap_rate: The exit cap rate used to value the property at sale (e.g., 0.06 for 6%)
  • entry_noi: NOI at purchase (NOI₀)
  • noi_growth: Expected annual NOI growth rate (g) (e.g., 0.03 for 3%)
  • holding_period: Holding period in years (H)

The model assumes:

  • Annual NOI cash flows: NOI₀ * (1 + noi_growth) ** t for t = 1, …, H.
  • At exit, the property is sold at:

    Sale Price = NOI₀ * (1 + noi_growth) ** (H + 1) / exit_cap_rate

  • That sale price is discounted back to time 0.

The DCF equation is:

Asking Price = Σₜ₌₁ᴴ [ NOI₀ * (1 + noi_growth)^t / (1 + target_irr)^t ]
                  + [ NOI₀ * (1 + noi_growth)^(H + 1) / exit_cap_rate ]
                  / (1 + target_irr)^H

Parameters:

Name Type Description Default
target_irr float

The target internal rate of return (IRR) as a decimal (e.g., 0.10 for 10%).

required
exit_cap_rate float

The exit cap rate as a decimal (e.g., 0.06 for 6%).

required
entry_noi float

The net operating income (NOI) at the time of purchase.

required
noi_growth float

The expected annual growth rate of NOI as a decimal (e.g., 0.03 for 3%).

required
holding_period int

The holding period in years.

required

Returns:

Type Description
tuple[float, float]

The entry and exit prices

Source code in openavmkit/income.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def derive_prices(
    target_irr: float,
    exit_cap_rate: float,
    entry_noi: float,
    noi_growth: float,
    holding_period: int,
) -> tuple[float, float]:
    """Calculate the entry and exit prices based on a DCF model given:

    - **target_irr**: The target internal rate of return (IRR) (e.g., 0.10 for 10%)
    - **exit_cap_rate**: The exit cap rate used to value the property at sale (e.g., 0.06 for 6%)
    - **entry_noi**: NOI at purchase (NOI₀)
    - **noi_growth**: Expected annual NOI growth rate (g) (e.g., 0.03 for 3%)
    - **holding_period**: Holding period in years (H)

    The model assumes:

    - Annual NOI cash flows: `NOI₀ * (1 + noi_growth) ** t` for `t = 1, …, H`.
    - At exit, the property is sold at:

        Sale Price = `NOI₀ * (1 + noi_growth) ** (H + 1)` / `exit_cap_rate`

    - That sale price is discounted back to time 0.

    The DCF equation is:

    ```
    Asking Price = Σₜ₌₁ᴴ [ NOI₀ * (1 + noi_growth)^t / (1 + target_irr)^t ]
                      + [ NOI₀ * (1 + noi_growth)^(H + 1) / exit_cap_rate ]
                      / (1 + target_irr)^H
    ```

    Parameters
    ----------
    target_irr : float
        The target internal rate of return (IRR) as a decimal (e.g., 0.10 for 10%).
    exit_cap_rate : float
        The exit cap rate as a decimal (e.g., 0.06 for 6%).
    entry_noi : float
        The net operating income (NOI) at the time of purchase.
    noi_growth : float
        The expected annual growth rate of NOI as a decimal (e.g., 0.03 for 3%).
    holding_period : int
        The holding period in years.

    Returns
    -------
    tuple[float, float]
        The entry and exit prices
    """

    noi_mult = 1 + noi_growth
    irr_mult = 1 + target_irr

    # net operating income is present net operating income compounded for expected growth
    exit_noi = entry_noi * (noi_mult**holding_period)

    # exit price is expected net operating income @ exit time, divided by expected cap rate @ exit time
    exit_price = exit_noi / exit_cap_rate

    # now we get the net present value of all future cash flows

    # first, we get the sum of the discounted cash flows for every year but the last
    npv = sum(
        [entry_noi * (noi_mult**t) / (irr_mult**t) for t in range(1, holding_period)]
    )
    # then, for the last year, we add the exit price to the last year's net operating income, and discount them together
    npv += (exit_noi + exit_price) / (irr_mult**holding_period)

    # the entry price is the net present value of all future discounted cash flows
    entry_price = npv

    return entry_price, exit_price