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₁)
Table of Contents
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 1.076186
2 0.962679
3 1.024954
4 1.452484
Name: seasonal_ratio, dtype: float64So, how do I infer this seasonal ratio and adjust revenue?
I use a simple lagged seasonal normalization inspired by seasonal-adjustment methods:
- Take the rolling 8-quarter mean of nominal revenue at each point in time.
- Divide each quarter’s revenue by this trailing 8-quarter average to estimate a seasonal ratio.
- To avoid look-ahead bias, use the seasonal ratio observed for the same quarter one year earlier. 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. 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 eight consecutive quarters.
consecutive_8q = (
revenue["quarter_id"]
.diff()
.eq(1)
.rolling(7, min_periods=7)
.sum()
.eq(7)
)
# Trailing eight-quarter average.
revenue["trailing_8q_average"] = (
revenue["revenue"]
.rolling(8, min_periods=8)
.mean()
.where(consecutive_8q)
)
# Revenue relative to its trailing average.
revenue["seasonal_ratio"] = (
revenue["revenue"] / revenue["trailing_8q_average"]
)
# Apply the factor observed for the same quarter one year earlier.
# This avoids using information from the current quarter to adjust itself.
previous_same_quarter = (
revenue.groupby("quarter")["quarter_id"].shift(1)
)
revenue["seasonal_factor"] = (
revenue.groupby("quarter")["seasonal_ratio"]
.shift(1)
.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%.
Importantly, 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 estimate counterfactual expected revenue using the average seasonally adjusted growth of the previous 8 quarters and the median seasonally adjusted revenue level of the previous 4 quarters. Using the median is intentional: it smooths the baseline so that one unusually high quarter does not dominate EAD. The trade-off is that this baseline can lag the latest revenue level in a strongly trending business.
Show EAD code
# Expected growth:
# average seasonally adjusted growth over the previous eight quarters.
revenue["expected_growth"] = (
revenue["revenue_growth_sa"]
.shift(1)
.rolling(8, min_periods=8)
.mean()
)
# Smoothed baseline:
# median seasonally adjusted revenue over the previous four quarters.
revenue["baseline_sa"] = (
revenue["revenue_sa"]
.shift(1)
.rolling(4, min_periods=4)
.median()
)
# Counterfactual nominal revenue expected for the quarter.
revenue["expected_revenue"] = (
revenue["baseline_sa"]
* (1 + revenue["expected_growth"])
* revenue["seasonal_factor"]
)
revenue["revenue_vs_expected"] = (
revenue["revenue"] / revenue["expected_revenue"] - 1
)
adjusted = (
revenue.loc[revenue["revenue_sa"].notna()]
.sort_values("quarter_id", kind="stable")
.reset_index(drop=True)
)
# Identified impairment quarters.
impairment_dates = adjusted.loc[
adjusted["impairment"].eq(True)
].copy()The plot below shows the historical seasonally adjusted revenue trajectory and the identified impairment events.
Show plot seasonally adjusted revenue and impairments code
fig, ax = plt.subplots(figsize=(13, 5))
ax.plot(
adjusted["quarter_end"],
adjusted["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=(
"Impairment event "
f"(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",
"expected_revenue",
]
]
.rename(
columns={
"period": "quarter",
"revenue_growth_sa": "sa_qoq_growth",
"revenue": "nominal_revenue",
}
)
.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}",
"expected_revenue": lambda x: (
f"{x:,.0f}" if pd.notna(x) else "N/A"
),
},
)
)
quarter sa_qoq_growth nominal_revenue expected_revenue
2011Q3 -18.0% 28,270 NaN
2012Q2 -12.3% 35,023 NaN
2015Q4 -12.1% 75,872 92,816
2016Q1 -11.0% 50,557 67,041
2018Q4 -18.0% 84,310 104,728
2021Q2 -10.1% 81,434 73,588
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",
"expected_growth",
"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()
pd1_history = pd1_history.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 "
f"{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
# Select the most recent origin followed by
# an impairment within four quarters.
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",
"expected_growth",
"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["expected_growth"],
color="#64717d",
ls="--",
marker="o",
lw=2,
label="Historical expected SA QoQ growth",
)
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}\n"
f"Historical realized impairment frequency (PD₁): "
f"{pd1:.1%} ({len(pd1_history)} overlapping windows)",
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.
Leave a Reply