Markets: A Rigorous Course

Exercise · m06a_pead.py

Earnings surprises and post-earnings-announcement drift (PEAD)

Exercise stubs. Replace each raise NotImplementedError with your code, then run the tests in ../tests. See notes.md 6A.2 (Definition 6A.1, Propositions 6A.2–6A.3).

Two parts:

  1. SUE from a quarterly EPS series with the seasonal-random-walk-with-drift expectation model.
  2. An event study of drift on a synthetic panel in which the drift is injected, so you can check that your estimator recovers what was put in.

Tests (5)

  • test_sue_hand_example
  • test_assign_groups_balanced
  • test_car_sums_window
  • test_drift_recovered
  • test_no_drift_no_signal

Theory: M06A notes. Run with Ctrl/⌘+Enter.

not run yet

Not saved: sign up to keep your code
"""M06A · Earnings surprises and post-earnings-announcement drift (PEAD).

Exercise stubs. Replace each ``raise NotImplementedError`` with your code, then run the tests in
../tests. See notes.md §6A.2 (Definition 6A.1, Propositions 6A.2–6A.3).

Two parts:
1. SUE from a quarterly EPS series with the seasonal-random-walk-with-drift expectation model.
2. An event study of drift on a synthetic panel in which the drift is injected, so you can check
   that your estimator recovers what was put in.
"""

from __future__ import annotations

import numpy as np
import statsmodels.api as sm  # noqa: F401  (for drift_regression)


# ---------------------------------------------------------------------------------------------
# Provided helper (not a stub): synthetic event panel with injected drift.
# ---------------------------------------------------------------------------------------------
def make_event_panel(n_events: int = 20000, horizon: int = 60, day0: float = 0.015,
                     drift: float = 0.012, sigma: float = 0.02, seed: int = 0):
    """Synthetic abnormal returns around earnings announcements.

    SUE_i ~ N(0, 1) independently.  Abnormal returns, for event-time tau = 0..horizon:
        AR_{i,0}   = day0 * SUE_i + eps_{i,0}
        AR_{i,tau} = (drift / horizon) * SUE_i + eps_{i,tau},   tau = 1..horizon
    with eps iid N(0, sigma^2). So the expected post-announcement CAR(1, horizon) of an event with
    surprise s is drift * s.
    Returns (sue, ar) with sue of shape (n_events,) and ar of shape (n_events, horizon + 1);
    column j of ar is event day tau = j.
    """
    rng = np.random.default_rng(seed)
    sue = rng.standard_normal(n_events)
    ar = rng.normal(0.0, sigma, size=(n_events, horizon + 1))
    ar[:, 0] += day0 * sue
    ar[:, 1:] += (drift / horizon) * sue[:, None]
    return sue, ar


# ---------------------------------------------------------------------------------------------
# Exercises
# ---------------------------------------------------------------------------------------------
def sue(eps, window: int = 8, min_obs: int = 4) -> np.ndarray:
    """Standardised unexpected earnings, seasonal random walk with drift (Definition 6A.1).

    eps: quarterly EPS, oldest first. Let d_q = eps_q - eps_{q-4} (seasonal difference).
    For quarter q, take the previous `window` seasonal differences d_{q-window}, ..., d_{q-1}
    (ignore NaNs); if fewer than `min_obs` are available, or their std is 0, SUE_q = NaN.
    Otherwise delta_q = their mean, s_q = their sample std (ddof=1), SUE_q = (d_q - delta_q) / s_q.
    Returns an array the same length as eps (NaN where undefined).
    """
    raise NotImplementedError


def assign_groups(x, n_groups: int = 10) -> np.ndarray:
    """Rank-based group labels 0..n_groups-1 (0 = lowest x). Use ranks 0..n-1 (ties broken by
    order of appearance, i.e. a stable sort) and label = floor(rank * n_groups / n)."""
    raise NotImplementedError


def car(ar, start: int, end: int) -> np.ndarray:
    """Cumulative abnormal return per event over event days start..end inclusive (columns of ar)."""
    raise NotImplementedError


def group_car(ar, sue_values, start: int, end: int, n_groups: int = 10) -> np.ndarray:
    """Mean CAR(start, end) for each SUE group (length n_groups, group 0 = most negative SUE)."""
    raise NotImplementedError


def drift_spread(ar, sue_values, start: int = 1, end: int = 60, n_groups: int = 10):
    """Top-minus-bottom group difference in mean CAR(start, end), with its standard error and t.

    se = sqrt(var_top / n_top + var_bottom / n_bottom) with sample variances (ddof=1).
    Returns (spread, se, t).
    """
    raise NotImplementedError


def drift_regression(ar, sue_values, start: int = 1, end: int = 60):
    """OLS of CAR(start, end) on a constant and SUE. Returns (slope, t_slope) with the usual
    homoskedastic standard error. Use statsmodels: sm.OLS(y, sm.add_constant(x)).fit()."""
    raise NotImplementedError


def expected_extreme_spread(n_groups: int = 10) -> float:
    """E[Z | top group] - E[Z | bottom group] for Z ~ N(0,1) and equal-probability groups.
    Derive it (Proposition 6A.3, Problem 6A.3): E[Z | Z > c] = phi(c) / P(Z > c). Use scipy.stats.norm."""
    raise NotImplementedError

No runs yet

The stub's functions raise NotImplementedError until you write them. Fill in one, press Run tests, and watch its tests turn green.

Reference solution

Available after your first run, with an account approved for code runs.

Create a free account

Reading is open to everyone. An account keeps your progress, answers, notes and code drafts, and gives you the study plan, the journals and the simulator. Running code needs an account approved for it.