Author: Admin

  • Apple Revenue LGD: 4 Powerful Insights Into Recovery

    In this post, part of my revenue risk series, I introduce an Apple revenue LGD model to measure the recovery following a revenue impairment. The objective is to estimate Loss Given Default (LGD) using historical Apple revenue data. The concepts used here are borrowed terminology from credit risk modeling. I’m applying the principles of credit risk frameworks to model large revenue reductions, instead of principal defaults.

    In the previous post, Apple Revenue Risk: Estimating Probability of Default, I identified historical revenue impairments and calculated a 4-quarter realized impairment frequency (PD₁). A revenue impairment occurs when seasonally adjusted quarter-on-quarter revenue growth falls below -10%.

    In this post, I extend the framework by measuring how revenue recovers following an impairment. This introduces a second dimension of Apple revenue risk: the severity of the remaining revenue shortfall.

    This post covers the implementation in Python:

    • Defining the 4-quarter LGD recovery window
    • Calculating the relative revenue shortfall
    • Estimating terminal Loss Given Default (LGD)

    Defining the Apple revenue LGD recovery window

    In credit risk, Loss Given Default measures the severity of a loss conditional on a default having occurred. For revenue risk, I use the same principle to measure the remaining revenue shortfall following an impairment.

    As I used a 4-quarter horizon for PD₁, I apply the same horizon to measuring recovery. Each LGD window starts in the quarter of impairment (td) and includes the following three quarters.

    I use the dataset prepared in the previous post, containing seasonally adjusted revenue, revenue growth, impairment events, and expected revenue. The expected revenue is my Exposure at Default (EAD) proxy, defined as the seasonally adjusted revenue of the quarter preceding impairment.

    For each impairment event, EAD remains fixed throughout the recovery window. This provides a consistent reference point for measuring how much of the initial revenue level has been recovered.

    Show LGD recovery window code
    # LGD window: the impairment quarter t_d and the next three quarters.
    # Use adjusted revenue from t_d - 1 as fixed EAD. The default-quarter
    # shortfall is therefore the decline measured by adjusted QoQ growth.
    
    lgd_data = adjusted.sort_values(
        "quarter_id", kind="stable"
    ).reset_index(drop=True)
    
    lgd_defaults = (
        lgd_data.loc[
            lgd_data["impairment"].eq(True),
            ["period", "quarter_id", "expected_revenue"],
        ]
        .rename(columns={
            "period": "default_quarter",
            "quarter_id": "default_quarter_id",
            "expected_revenue": "ead_at_default",
        })
    )
    
    lgd_observations = lgd_data[
        [
            "quarter_id",
            "period",
            "impairment",
            "revenue_growth_sa",
            "revenue_sa",
            "revenue",
        ]
    ].rename(columns={
        "quarter_id": "observation_quarter_id",
        "period": "observation_quarter",
        "revenue_sa": "observed_revenue_sa",
    })
    
    # Join each default to the default quarter
    # and the next three calendar quarters.
    
    lgd_paths = lgd_defaults.merge(
        pd.DataFrame({"horizon_quarter": range(1, 5)}),
        how="cross",
    )
    
    lgd_paths["observation_quarter_id"] = (
        lgd_paths["default_quarter_id"]
        + lgd_paths["horizon_quarter"]
        - 1
    )
    
    lgd_paths = lgd_paths.merge(
        lgd_observations,
        on="observation_quarter_id",
        how="left",
        validate="many_to_one",
    )
    
    # Require four observed quarters.
    
    complete_lgd_window = (
        lgd_paths.groupby("default_quarter_id")["observed_revenue_sa"]
        .transform("count")
        .eq(4)
    )
    
    lgd_paths = (
        lgd_paths.loc[complete_lgd_window]
        .sort_values(["default_quarter_id", "horizon_quarter"])
        .reset_index(drop=True)
    )

    The chart below illustrates an LGD recovery window. The horizontal line represents EAD at the moment of impairment, while the observed seasonally adjusted revenue shows how revenue develops during the following quarters.

    Show LGD recovery window chart code
    # Illustrate complete window with an observed EAD.
    illustrative_default = pd.Period('2018Q4', 'Q-DEC')
    
    if illustrative_default is not None:
    
        illustrative_window = lgd_paths.loc[
            lgd_paths["default_quarter"].eq(illustrative_default)
        ]
    
        horizon = illustrative_window["horizon_quarter"].to_numpy()
        observed = illustrative_window["observed_revenue_sa"].to_numpy()
        ead_at_default = illustrative_window["ead_at_default"].iloc[0]
    
        fig, ax = plt.subplots(figsize=(9, 5))
    
        ax.plot(
            horizon,
            observed,
            color="#0756b1",
            marker="o",
            lw=2.5,
            label="Observed seasonally adjusted revenue",
        )
    
        ax.axhline(
            ead_at_default,
            color="#d52b24",
            ls="--",
            lw=2,
            label="EAD at default (fixed)",
        )
    
        ax.fill_between(
            horizon,
            observed,
            ead_at_default,
            where=observed < ead_at_default,
            color="#d52b24",
            alpha=0.15,
        )
    
        ax.set_xticks(
            horizon,
            illustrative_window["observation_quarter"].astype(str),
        )
    
        ax.set_ylabel("Seasonally adjusted revenue (M USD)")
    
        ax.set_title(
            f"AAPL LGD observation window: default {illustrative_default}",
            loc="left",
            fontweight="bold",
        )
    
        ax.grid(axis="y", alpha=0.3)
        ax.spines[["top", "right"]].set_visible(False)
        ax.legend(frameon=False)
    
        fig.tight_layout()
    
        fig.savefig(
            "aapl_revenue_lgd_window.png",
            dpi=180,
            bbox_inches="tight",
        )
    
        plt.show()
    
    else:
        print("AAPL LGD: no complete four-quarter window has an observed EAD.")

    Whenever observed revenue falls below EAD, the difference represents a revenue shortfall. A recovery occurs when this difference decreases over time. Once observed revenue reaches or exceeds EAD, the shortfall is considered fully recovered.

    Calculating the relative revenue shortfall

    The next step in the Apple revenue LGD calculation is to measure the relative revenue shortfall for each quarter in the recovery window.

    I measure this as the difference between EAD at default and observed seasonally adjusted revenue, divided by EAD at default. Negative shortfalls are set to zero, as revenue exceeding the reference level is considered a full recovery rather than a negative loss.

    For example, if EAD is 100 and observed revenue is 85, the relative shortfall is 15%. If revenue subsequently recovers to 95, the remaining shortfall decreases to 5%.

    This measure allows me to compare recovery paths across different impairment events in the Apple revenue LGD framework, regardless of the absolute revenue level at the time of impairment.

    Show relative revenue shortfall calculation
    # Relative shortfall: unrecovered adjusted revenue as a share of fixed EAD.
    
    lgd_paths["relative_shortfall"] = (
        (
            lgd_paths["ead_at_default"]
            - lgd_paths["observed_revenue_sa"]
        )
        / lgd_paths["ead_at_default"]
    ).clip(lower=0)
    
    lgd_paths = (
        lgd_paths.sort_values(
            ["default_quarter_id", "horizon_quarter"]
        )
        .loc[:, [
            "default_quarter",
            "horizon_quarter",
            "observation_quarter",
            "impairment",
            "revenue_growth_sa",
            "ead_at_default",
            "observed_revenue_sa",
            "revenue",
            "relative_shortfall",
        ]]
        .reset_index(drop=True)
    )

    The following chart shows an example of the relative shortfall trajectory following a revenue impairment.

    Show relative revenue shortfall chart code
    if illustrative_default is not None:
    
        illustrative_shortfall = lgd_paths.loc[
            lgd_paths["default_quarter"].eq(illustrative_default)
        ]
    
        horizon = illustrative_shortfall["horizon_quarter"].to_numpy()
        shortfall = illustrative_shortfall["relative_shortfall"].to_numpy()
    
        fig, ax = plt.subplots(figsize=(9, 5))
    
        ax.plot(
            horizon,
            shortfall,
            color="#d52b24",
            marker="o",
            lw=2.5,
        )
    
        ax.fill_between(
            horizon,
            shortfall,
            color="#d52b24",
            alpha=0.15,
        )
    
        ax.set_xticks(
            horizon,
            illustrative_shortfall["observation_quarter"].astype(str),
        )
    
        ax.yaxis.set_major_formatter(PercentFormatter(1))
        ax.set_ylabel("Revenue shortfall / EAD at default")
    
        ax.set_title(
            f"AAPL relative revenue shortfall: default {illustrative_default}",
            loc="left",
            fontweight="bold",
        )
    
        ax.grid(axis="y", alpha=0.3)
        ax.spines[["top", "right"]].set_visible(False)
    
        fig.tight_layout()
    
        fig.savefig(
            "aapl_revenue_lgd.png",
            dpi=180,
            bbox_inches="tight",
        )
    
        plt.show()

    The relative shortfall represents the proportion of EAD that remains unrecovered at each point in time. A declining shortfall indicates recovery, while an increasing shortfall indicates that revenue has moved further below the reference level.

    Importantly, this is a period-specific measure rather than a cumulative sum of revenue losses. It measures the remaining shortfall in each quarter relative to the fixed EAD at default.

    Estimating terminal Loss Given Default (LGD)

    The final step in the Apple revenue LGD framework is to estimate terminal Loss Given Default (LGD). I define terminal LGD as the relative revenue shortfall observed in the fourth and final quarter of the recovery window.

    This provides a simple measure of the remaining revenue impairment after one year. If revenue has fully recovered to its EAD reference level, LGD equals 0%. Otherwise, LGD represents the proportion of EAD that remains unrecovered.

    The calculation is deliberately different from the cumulative loss over the entire recovery period. It measures the remaining shortfall at the end of the horizon, not the sum of shortfalls experienced during the preceding quarters.

    Show terminal LGD calculation
    # Terminal LGD: the relative shortfall in the fourth observed quarter.
    
    if lgd_paths.empty:
    
        print(
            "AAPL LGD: no impairment has a complete "
            "four-quarter observation window."
        )
    
    else:
    
        # The terminal shortfall, including zero after full recovery, is LGD.
    
        lgd_history = (
            lgd_paths.loc[
                lgd_paths["horizon_quarter"].eq(4),
                [
                    "default_quarter",
                    "observation_quarter",
                    "ead_at_default",
                    "relative_shortfall",
                ],
            ]
            .rename(columns={
                "observation_quarter": "horizon_end",
                "relative_shortfall": "lgd",
            })
            .reset_index(drop=True)
        )
    
        print(
            "AAPL LGD: last observed relative revenue "
            "shortfall over four quarters"
        )
    
        print(
            lgd_history.to_string(
                index=False,
                formatters={
                    "ead_at_default": lambda x: f"{x:,.0f}",
                    "lgd": lambda x: f"{x:.1%}",
                },
            )
        )
    
        measured_lgd = lgd_history.loc[
            lgd_history["lgd"].notna()
        ]

    Using the seven identified Apple revenue impairments, I obtain the following terminal LGD observations:

    default_quarter horizon_end ead_at_default  lgd
    2012Q2          2013Q1         37,101       0.0%
    2013Q2          2014Q1         40,425       0.0%
    2016Q1          2016Q4         54,109       0.0%
    2017Q1          2017Q4         59,175       0.0%
    2018Q1          2018Q4         66,683      12.0%
    2018Q4          2019Q3         71,361       2.2%
    2022Q4          2023Q3         95,623       0.7%

    Four of the seven impairment events resulted in a terminal LGD of 0%, meaning that seasonally adjusted revenue had fully recovered to its fixed EAD reference level by the end of the recovery window.

    The remaining three events show a positive terminal LGD, with the highest observed shortfall occurring following the 2018-Q1 impairment.

    To summarize these observations, I calculate the mean, median, minimum, and maximum terminal LGD.

    Show terminal LGD statistics code
    if measured_lgd.empty:
    
        print(
            "Mean LGD: unavailable; no complete "
            "default window has an EAD."
        )
    
    else:
    
        terminal_lgd = measured_lgd["lgd"]
    
        print(
            f"Terminal LGD statistics "
            f"({len(terminal_lgd)} complete windows with EAD): "
            f"mean {terminal_lgd.mean():.1%}, "
            f"median {terminal_lgd.median():.1%}, "
            f"min {terminal_lgd.min():.1%}, "
            f"max {terminal_lgd.max():.1%}"
        )
    Terminal LGD statistics (7 complete windows with EAD):
    
    Mean:    2.1%
    Median:  0.0%
    Minimum: 0.0%
    Maximum: 12.0%

    The historical mean terminal LGD is 2.1%, while the median is 0.0%. These Apple revenue LGD results suggest that most identified impairments were followed by a full recovery to the fixed EAD reference level within the defined horizon.

    However, the observations also show that recovery is not guaranteed. In some cases, a meaningful revenue shortfall remains after four quarters.

    These results should be interpreted as descriptive historical recovery outcomes rather than predictive LGD estimates. The sample contains only seven impairment events, and the fixed EAD reference is a deliberately simple counterfactual that does not incorporate the revenue growth that might otherwise have occurred.

    Terminal LGD does not capture the revenue shortfalls experienced earlier in the recovery window. It measures the remaining impairment at the end of the horizon rather than the total revenue forgone following default.

    What comes next?

    With historical PD₁ and Apple revenue LGD measures established, the next step is to develop predictive models using relevant economic indicators.

    For this, I’ll use Federal Reserve Economic Data (FRED), maintained by the Federal Reserve Bank of St. Louis. FRED provides access to a large collection of historical economic and financial time series, including interest rates, consumer confidence, unemployment, and industrial production.

    FRED is useful because these indicators may contain information about changing economic conditions before their effects become visible in company revenue. Its standardized historical data and Python-accessible API also make it convenient to retrieve and compare potential leading indicators.

    In the next post, I’ll explore whether selected FRED indicators can help predict the occurrence and severity of Apple’s revenue impairments, moving from historical measurements toward a predictive revenue risk framework.

  • Apple Revenue Risk: 3 Powerful Steps to Estimate PD

    In this post, part of my revenue risk series, I use SEC EDGAR data to build an Apple revenue risk framework based on historical revenue impairment events. The objective is to estimate a 4-quarter realized impairment frequency, which I refer to as PD₁ by analogy with Probability of Default (PD).

    In the previous post, Revenue Risk: 3 Models for Measuring Downside, I defined a revenue impairment as a quarter in which seasonally adjusted revenue growth falls below -10% QoQ.

    This post covers the implementation in Python:

    • Adjusting Apple revenue for seasonality
    • Defining revenue impairment events
    • Estimating a 4-quarter realized impairment frequency (PD₁)

    Adjusting Apple revenue for seasonality

    Seasonality is an important part of the Apple revenue risk calculation because quarterly revenue follows a strong recurring pattern. I use a sample of SEC EDGAR data spanning 2008-Q2 through 2026-Q1. Across the full sample, Q4 has the highest average seasonal ratio, while Q2 has the lowest.

    Using pandas, we can confirm this:

    revenue.groupby("quarter")["seasonal_ratio"].mean()

    Output:

    quarter
    1    0.995295
    2    0.894631
    3    0.965097
    4    1.356130
    Name: seasonal_ratio, dtype: float64

    So, how do I infer this seasonal ratio and adjust revenue?

    I use a simple lagged seasonal normalization inspired by seasonal-adjustment methods:

    1. Take the rolling 4-quarter mean of nominal revenue at each point in time.
    2. Divide each quarter’s revenue by this trailing 4-quarter average to estimate a seasonal ratio.
    3. To avoid look-ahead bias, use the median of up to five seasonal ratios observed for the same quarter in previous years, requiring at least three. Dividing current revenue by this lagged factor produces the seasonally normalized revenue series.

    This is deliberately a simple normalization rather than an estimate of a pure statistical seasonal component. The median limits the influence of an isolated impairment on later seasonal factors. Because the denominator is a moving average, the ratio can also reflect trend and business-cycle effects.

    For comparison, the chart below shows nominal and seasonally adjusted revenue. The adjusted series forms the basis for identifying Apple revenue risk events.

    Show full seasonality-adjustment code
    revenue["quarter"] = revenue["period"].dt.quarter
    revenue["quarter_id"] = revenue["period"].dt.year * 4 + revenue["quarter"]
    
    # Require four consecutive quarters for the trailing trend correction.
    consecutive_4q = (
        revenue["quarter_id"]
        .diff()
        .eq(1)
        .rolling(3, min_periods=3)
        .sum()
        .eq(3)
    )
    
    # Trailing four-quarter average.
    revenue["trailing_4q_average"] = (
        revenue["revenue"]
        .rolling(4, min_periods=4)
        .mean()
        .where(consecutive_4q)
    )
    
    # Revenue relative to its trailing average.
    revenue["seasonal_ratio"] = (
        revenue["revenue"] / revenue["trailing_4q_average"]
    )
    
    # Use the median of up to five prior same-quarter ratios, requiring
    # at least three. The shift excludes the current quarter.
    previous_same_quarter = (
        revenue.groupby("quarter")["quarter_id"].shift(1)
    )
    
    revenue["seasonal_factor"] = (
        revenue.groupby("quarter")["seasonal_ratio"]
        .transform(
            lambda x: x.shift(1)
            .rolling(window=5, min_periods=3)
            .median()
        )
        .where(
            revenue["quarter_id"]
            .sub(previous_same_quarter)
            .eq(4)
        )
    )
    
    # Seasonally normalized revenue.
    revenue["revenue_sa"] = (
        revenue["revenue"] / revenue["seasonal_factor"]
    )
    
    # Plot nominal versus seasonally adjusted revenue.
    fig, ax = plt.subplots(figsize=(13, 5))
    
    ax.plot(
        revenue["quarter_end"],
        revenue["revenue"],
        color="#0756b1",
        label="Nominal quarterly revenue",
    )
    
    ax.plot(
        revenue["quarter_end"],
        revenue["revenue_sa"],
        color="#d8781e",
        label="Seasonally adjusted quarterly revenue",
    )
    
    ax.set_title("AAPL Quarterly Revenue: Nominal vs Seasonally Adjusted")
    ax.set_xlabel("Quarter end")
    ax.set_ylabel("Revenue (M USD)")
    ax.grid(alpha=0.25)
    ax.legend()
    
    fig.tight_layout()
    
    fig.savefig(
        "aapl_nominal_vs_seasonally_adjusted_revenue.png",
        dpi=180,
        bbox_inches="tight",
    )
    
    plt.show()

    Defining Apple revenue impairment events

    The next step in measuring Apple revenue risk is to identify revenue impairment events. The definition itself is simple: an impairment occurs when seasonally adjusted quarter-on-quarter revenue growth falls below -10%.

    The impairment event is determined only by seasonally adjusted revenue growth. Expected revenue and Exposure at Default (EAD) do not determine whether an impairment occurs or enter the PD₁ calculation.

    Show impairment-event code
    # Calculate QoQ growth after adjusting the revenue level for seasonality.
    revenue["revenue_growth_sa"] = (
        revenue["revenue_sa"]
        .pct_change(fill_method=None)
        .where(revenue["quarter_id"].diff().eq(1))
    )
    
    # Revenue impairment:
    # seasonally adjusted QoQ revenue growth below -10%.
    revenue["impairment_threshold"] = -0.10
    
    valid = revenue["revenue_growth_sa"].notna()
    
    revenue["impairment"] = (
        revenue["revenue_growth_sa"]
        < revenue["impairment_threshold"]
    ).where(valid)

    I nevertheless calculate expected revenue here because it provides the EAD measure that will be used later when modeling the severity and recovery of an impairment. It also lets me describe the identified impairment events in more detail.

    I set the counterfactual expected revenue, used as Exposure At Default (EAD), as the seasonally adjusted revenue level of the preceding quarter. This is the same base used to calculate seasonally adjusted quarter-on-quarter growth, so the initial relative shortfall matches the revenue decline at impairment.

    Show EAD code
    
    # Fix EAD at the preceding quarter's seasonally adjusted revenue. Use the
    # same consecutive-quarter requirement as the QoQ impairment-growth measure.
    revenue["expected_revenue"] = (
        revenue["revenue_sa"].shift(1)
        .where(revenue["quarter_id"].diff().eq(1))
    )
    
    adjusted = (
        revenue.loc[revenue["revenue_sa"].notna()]
        .sort_values("quarter_id", kind="stable")
        .reset_index(drop=True)
    )
    

    The plot below shows the historical seasonally adjusted revenue trajectory and the identified impairment events.

    Show plot seasonally adjusted revenue and impairments code
    
    # Mark observed impairment quarters on seasonally adjusted revenue.
    # The event test uses growth inferred from this same adjusted level series.
    impairment_dates = adjusted.loc[adjusted["impairment"].eq(True)].copy()
    fig, ax = plt.subplots(figsize=(13, 5))
    ax.plot(revenue["quarter_end"], revenue["revenue_sa"], color="#0756b1", lw=1.8,
            label="Seasonally adjusted quarterly revenue")
    ax.scatter(
        impairment_dates["quarter_end"], impairment_dates["revenue_sa"],
        color="#c92626", s=65, zorder=3,
        label=f"Impairment event (SA QoQ growth < -10%; {len(impairment_dates)} quarters)",
    )
    ax.set_title("AAPL Seasonally Adjusted Revenue and Impairment Events")
    ax.set_xlabel("Quarter end")
    ax.set_ylabel("Seasonally adjusted revenue (M USD)")
    ax.grid(alpha=0.25)
    ax.legend()
    fig.tight_layout()
    fig.savefig("aapl_seasonally_adjusted_revenue_impairments.png", dpi=180,
                bbox_inches="tight")
    plt.show()
    

    To make the impairment events easier to inspect, I also summarize the event quarter, seasonally adjusted QoQ growth, nominal revenue, and counterfactual expected revenue.

    Show summary code
    
    impairment_summary = (
        impairment_dates[
            [
                "period",
                "revenue_growth_sa",
                "revenue",
                "revenue_sa",
                "expected_revenue",
            ]
        ]
        .rename(
            columns={
                "period": "quarter",
                "revenue_growth_sa": "sa_qoq_growth",
                "revenue": "nominal_revenue",
                "revenue_sa": "observed_revenue_sa",
                "expected_revenue": "expected_revenue_sa",
            }
        )
        .reset_index(drop=True)
    )
    
    print(
        impairment_summary.to_string(
            index=False,
            formatters={
                "sa_qoq_growth": lambda x: f"{x:.1%}",
                "nominal_revenue": lambda x: f"{x:,.0f}",
                "observed_revenue_sa": lambda x: f"{x:,.0f}",
                "expected_revenue_sa": lambda x: (
                    f"{x:,.0f}" if pd.notna(x) else "N/A"
                ),
            },
        )
    )
    
    
    quarter sa_qoq_growth nominal_revenue observed_revenue_sa expected_revenue_sa
     2012Q2        -14.2%          35,023              31,838              37,101
     2013Q2        -14.4%          35,323              34,606              40,425
     2016Q1        -14.6%          50,557              46,226              54,109
     2017Q1        -13.8%          52,896              50,999              59,175
     2018Q1        -11.1%          61,137              59,276              66,683
     2018Q4        -17.8%          84,310              58,677              71,361
     2022Q4        -10.7%         117,154              85,386              95,623
    

    Estimating the 4-quarter realized impairment frequency (PD₁)

    The final step is to translate the impairment events into a 4-quarter realized impairment frequency (PD₁). For each historical quarter, I ask whether at least one impairment occurred in any of the next 4 consecutive quarters. If so, the realized outcome is True (1); otherwise it is False (0).

    I use the historical share of complete four-quarter windows containing at least one impairment as the PD₁ estimate. This is a descriptive historical incidence rate, not a predictive probability model.

    For the PD₁ calculation I use a separate dataframe that requires only a valid impairment observation and consecutive quarter IDs. Expected revenue is intentionally not part of this filter because EAD is not required to determine whether an impairment occurred.

    Show 4-quarter PD₁ calculation
    
    # PD₁ uses only observations required to determine impairment.
    # expected_revenue is deliberately excluded from this filter.
    pd_data = (
        adjusted.loc[
            adjusted["impairment"].notna(),
            ["period", "quarter_id", "revenue_growth_sa", "impairment"],
        ]
        .sort_values("quarter_id", kind="stable")
        .reset_index(drop=True)
    )
    
    # Did an impairment occur in each of the next four observations?
    future_impairments = pd.concat(
        [pd_data["impairment"].shift(-offset) for offset in range(1, 5)],
        axis=1,
    )
    
    # Check that those observations are actually the next
    # four consecutive calendar quarters.
    future_quarter_ids = pd.concat(
        [pd_data["quarter_id"].shift(-offset) for offset in range(1, 5)],
        axis=1,
    )
    expected_quarter_ids = pd.concat(
        [pd_data["quarter_id"] + offset for offset in range(1, 5)],
        axis=1,
    )
    expected_quarter_ids.columns = future_quarter_ids.columns
    complete_windows = (
        future_impairments.notna().all(axis=1)
        & future_quarter_ids.eq(expected_quarter_ids).all(axis=1)
    )
    pd1_history = pd_data.loc[
        complete_windows, ["period", "revenue_growth_sa"]
    ].copy().rename(columns={"period": "origin_quarter"})
    pd1_history["impaired_next_4q"] = (
        future_impairments.loc[complete_windows].any(axis=1).to_numpy()
    )
    pd1_history = pd1_history.reset_index(drop=True)
    
    # Historical 4-quarter realized impairment frequency.
    pd1 = (
        pd1_history["impaired_next_4q"].mean()
        if not pd1_history.empty else float("nan")
    )
    print(
        f"AAPL 4-quarter realized impairment frequency (PD₁): "
        f"{pd1:.1%} over {len(pd1_history)} complete overlapping windows"
    )
    
    

    Because the four-quarter windows overlap, these observations are not independent trials. Adjacent origin quarters can share three of the same four future quarters. The resulting PD₁ should therefore be interpreted as a historical incidence rate rather than as an independently sampled default frequency.

    The chart below shows the most recent historical origin for which an impairment occurred within the following four-quarter window.

    Show PD₁ chart code
    
    # Use the most recent real AAPL window in which the next four quarters
    # contained an impairment.
    impaired_origins = pd1_history.loc[
        pd1_history["impaired_next_4q"], "origin_quarter"
    ]
    if impaired_origins.empty:
        raise ValueError("No observed AAPL impairment window is available.")
    
    origin = impaired_origins.iloc[-1]
    origin_quarter_id = pd_data.loc[
        pd_data["period"].eq(origin), "quarter_id"
    ].iloc[0]
    window = pd_data.loc[
        pd_data["quarter_id"].between(
            origin_quarter_id - 8, origin_quarter_id + 4),
        ["period", "quarter_id", "revenue_growth_sa", "impairment"],
    ].reset_index(drop=True)
    
    fig, ax = plt.subplots(figsize=(15, 7.5))
    x = range(len(window))
    origin_position = window.index[window["quarter_id"].eq(origin_quarter_id)][0]
    
    # Historical context before the origin quarter.
    ax.axvspan(-0.5, origin_position - 0.5,
               color="#e9f3fc", alpha=0.75, zorder=0,
               label="Historical context")
    # The four quarters following the origin.
    ax.axvspan(origin_position + 0.5, origin_position + 4.5,
               color="#fce8e6", alpha=0.45, zorder=0,
               label="Four-quarter PD₁ horizon")
    ax.plot(x, window["revenue_growth_sa"], color="#0756b1", marker="o", lw=2.5,
            label="Observed SA QoQ growth")
    ax.axhline(
        -0.10, color="#d52b24", ls="--", lw=1.8,
        label="Impairment threshold: -10% QoQ growth",
    )
    breaches = window["impairment"].eq(True)
    ax.scatter(
        window.index[breaches], window.loc[breaches, "revenue_growth_sa"],
        color="#d52b24", s=90, zorder=3, label="Observed impairment",
    )
    ax.set_xticks(list(x))
    ax.set_xticklabels([str(period) for period in window["period"]])
    ax.yaxis.set_major_formatter(PercentFormatter(1))
    ax.set_ylabel("Seasonally adjusted revenue growth (QoQ)")
    ax.set_title(
        f"AAPL: observed 4-quarter impairment window starting {origin}",
        loc="left", fontsize=15, fontweight="bold",
    )
    ax.grid(axis="y", alpha=0.3)
    ax.spines[["top", "right"]].set_visible(False)
    ax.legend(frameon=False)
    fig.tight_layout()
    fig.savefig("aapl_revenue_pd1.png", dpi=180, bbox_inches="tight")
    plt.show()
    

    This historical frequency provides a first empirical measure of Apple revenue risk. I use the PD₁ terminology to maintain the analogy with credit risk, but at this stage it remains a realized historical impairment frequency rather than a forward-looking probability estimate.

    What comes next?

    The next step in the Apple revenue risk framework is to model the recovery path conditional on a revenue impairment having occurred. This will introduce Loss Given Default (LGD) and make use of the counterfactual expected-revenue measure prepared above.

  • Revenue Risk: 3 Models for Measuring Downside

    Revenue risk is the risk that realized revenue will be lower than expected or below a defined threshold. This post introduces a new series dedicated to revenue risk. I will write several pieces each month about this personal project, including the modeling choices, assumptions, results, and limitations.

    I want to build a framework that focuses specifically on downside outcomes: how likely a meaningful revenue impairment is, how much revenue is exposed when it occurs, and how severe the shortfall becomes afterward.

    What is revenue risk?

    Revenue risk is the risk that realized revenue will be lower than expected or below a certain threshold. A standard forecast typically estimates the most likely path for future revenue. A revenue risk model asks a different question: what is the probability and severity of a materially adverse outcome?

    How do I approach revenue risk?

    My goal is to apply credit risk modeling techniques to revenue outcomes using historical data. I will adopt concepts such as Probability of Default (PD), Exposure at Default (EAD), and Loss Given Default (LGD), but reinterpret them for revenue impairment (a strong reduction in revenue) rather than debt default.

    The terminology is inspired by established credit risk frameworks. The Basel Framework – IRB approach: risk components, for example, uses PD, LGD, and EAD as core credit risk components. My use of these concepts is merely a useful analogy. I don’t intend to reproduce an entire regulatory credit risk model.

    How is this different from existing revenue risk frameworks?

    Existing approaches such as Revenue-at-Risk and Cash-Flow-at-Risk generally estimate the distribution or downside tail of future revenue or cash flow. Similar ideas are also used to quantify revenue exposed to specific disruptions.

    The novel step explored here is the specific decomposition of revenue impairment into PD, EAD, and LGD: the probability of a material revenue decline, the counterfactual revenue exposed when it occurs, and the portion of that revenue that remains unrecovered over time. This is a different way of structuring the revenue downside problem.

    What is revenue impairment?

    When modeling default risk for debt instruments, a model developer has to decide what the definition of a default is. For example, it could mean the event of a missed principal repayment that is at least 90 days overdue.

    I define “revenue impairment” as the event in which seasonally adjusted revenue falls more than 10%. It is similar to a default event in the sense that it represents a discrete downside event that can be assigned a probability and followed by a recovery path.

    ComponentCredit risk interpretationRevenue impairment implementation
    Probability of Default (PD)Probability that the borrower defaults within a defined horizon.Probability that seasonally adjusted revenue falls >10% in any quarter over the next 4 quarters.
    Exposure at Default (EAD)Amount of credit exposure outstanding when default occurs.Counterfactual revenue exposed at the moment of impairment: the revenue that would have been expected in the impairment quarter absent the impairment.
    Loss Given Default (LGD)Fraction of EAD ultimately lost after recoveries following default.Fraction of counterfactual revenue not recovered after impairment, measured from the revenue shortfall path until the end of a fixed horizon.

    How does PD work in a revenue risk model?

    PD₁ is defined as the probability that seasonally adjusted revenue growth (quarter-on-quarter) falls more than 10% in any of the next four quarters. The historical pattern is used to infer revenue seasonality. The forecast horizon shows whether an event occurs in which the threshold is breached.

    See this post (Apple Revenue Risk: 3 Powerful Steps to Estimate PD) for an implementation of this framework for Apple.

    How does EAD work in a revenue risk model?

    For a given revenue impairment event, EAD is the counterfactual revenue amount expected in the impairment quarter, assuming the impairment had not occurred. If 2025 Q2 expected revenue was $120m and actual seasonally adjusted revenue fell to $90m, then EAD = $120m. The $30m shortfall is not the exposure, it’s the loss.

    EAD provides the revenue base against which the size of the impairment can be measured.

    How does LGD work in a revenue risk model?

    LGD measures how much of the revenue impairment remains unrecovered at the end of a fixed recovery horizon. At the default event date (t₍d₎), EAD is fixed as the counterfactual revenue expected at that point in time.

    For each subsequent quarter, the revenue shortfall is measured relative to this fixed EAD at t₍d₎. The shortfall can decrease as revenue recovers. LGD is the last observed relative shortfall at the end of the LGD horizon.

    Earlier shortfalls describe the recovery path, while LGD captures the residual impairment that remains at the end of the measurement horizon. A full recovery therefore results in an LGD of 0%, even if material revenue shortfalls occurred in earlier quarters.

    Why model revenue risk this way?

    The purpose is to move beyond a regular revenue forecast and explicitly estimate downside risk. Separating probability, exposure, and loss severity creates a structured way to ask three different questions: how likely is the impairment, how much revenue is exposed when it happens, and how much of that exposure is ultimately lost over the recovery horizon?

    This also creates a framework that can be tested historically. Each component can be estimated separately, validated against realized outcomes, and improved as more observations become available.

    What comes next for the revenue risk model?

    The next step is to estimate each revenue risk component using historical revenue data. I will explore how PD, EAD, and LGD can be modeled separately, how seasonality should be handled, and how the three components can eventually be combined into a single expected revenue loss measure.

    The broader goal of this revenue risk series is to build the framework step by step, make the assumptions explicit, and test whether credit risk concepts can provide a useful way to quantify revenue downside.