Markets: A Rigorous Course

Exercise · m06a_merger_arb.py

Merger arbitrage: spreads, implied deal probability, annualised returns

Exercise stubs. Replace each raise NotImplementedError with your code, then run the tests in ../tests. See notes.md 6A.7 (Propositions 6A.10–6A.11).

Conventions#

  • p_target : current price of one target share (₹).
  • deal_value: value per target share delivered if the deal closes. For a cash deal this is the offer price; for a stock-for-stock deal with exchange ratio R it is R * P_acquirer (the arbitrageur shorts R acquirer shares, so the acquirer price is locked in).
  • fallback : estimated target price if the deal fails (the "downside").
  • T : time to close in years. r: simple annual financing rate.
  • carry : net cash received per target share while waiting (target dividends minus R * acquirer dividends owed on the short, plus any short rebate); default 0. Simple (not continuous) carry is used throughout: the arbitrageur finances p_target at rate r for T.

Tests (8)

  • test_stock_deal_value_and_spread_hdfc_style
  • test_implied_probability_closed_form
  • test_probability_round_trip
  • test_probability_rejects_bad_inputs
  • test_expected_return_and_breakeven_consistent
  • test_annualised_spread
  • test_kelly_binary
  • test_spread_series

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

not run yet

Not saved: sign up to keep your code
"""M06A · Merger arbitrage: spreads, implied deal probability, annualised returns.

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

Conventions
-----------
* ``p_target``  : current price of one target share (₹).
* ``deal_value``: value per target share delivered if the deal closes. For a cash deal this is the
  offer price; for a stock-for-stock deal with exchange ratio R it is R * P_acquirer (the arbitrageur
  shorts R acquirer shares, so the acquirer price is locked in).
* ``fallback``  : estimated target price if the deal fails (the "downside").
* ``T``         : time to close in years. ``r``: simple annual financing rate.
* ``carry``     : net cash received per target share while waiting (target dividends minus
  R * acquirer dividends owed on the short, plus any short rebate); default 0.
Simple (not continuous) carry is used throughout: the arbitrageur finances p_target at rate r for T.
"""

from __future__ import annotations

import numpy as np


def stock_deal_value(p_acquirer: float, ratio: float) -> float:
    """Deal value per target share in a stock-for-stock deal: R * P_A."""
    raise NotImplementedError


def gross_spread(p_target: float, deal_value: float) -> tuple[float, float]:
    """Return (spread in ₹, spread as a fraction of the target price).

    spread = deal_value - p_target;  pct = spread / p_target.
    """
    raise NotImplementedError


def implied_deal_probability(p_target: float, deal_value: float, fallback: float,
                             T: float = 0.0, r: float = 0.0, carry: float = 0.0) -> float:
    """Implied probability of completion (Proposition 6A.10).

    The arbitrage price satisfies
        p_target * (1 + r T) = p * deal_value + (1 - p) * fallback + carry,
    so solve for p. Raise ValueError if deal_value <= fallback. Do NOT clip the result: a value
    outside [0, 1] tells you the inputs (usually the fallback) are inconsistent with the price.
    """
    raise NotImplementedError


def expected_return(p_target: float, deal_value: float, fallback: float, p: float,
                    T: float = 0.0, carry: float = 0.0) -> float:
    """Expected holding-period return (not annualised) of the long-target, hedged position:

        E[R] = (p * deal_value + (1 - p) * fallback + carry) / p_target - 1.
    """
    raise NotImplementedError


def annualised_spread_return(p_target: float, deal_value: float, T: float,
                             carry: float = 0.0, compound: bool = True) -> float:
    """Annualised return if the deal closes at T years.

    Holding-period return h = (deal_value + carry) / p_target - 1.
    compound=True : (1 + h) ** (1 / T) - 1;  compound=False : h / T.
    Raise ValueError if T <= 0.
    """
    raise NotImplementedError


def breakeven_probability(p_target: float, deal_value: float, fallback: float,
                          T: float, hurdle: float, carry: float = 0.0) -> float:
    """Completion probability at which the expected simple annualised return equals ``hurdle``:
    solve p * deal_value + (1-p) * fallback + carry = p_target * (1 + hurdle * T) for p.
    Raise ValueError if deal_value <= fallback.
    """
    raise NotImplementedError


def kelly_fraction_binary(p: float, gain: float, loss: float) -> float:
    """Kelly fraction for a binary bet returning +gain (prob p) or -loss (prob 1-p), as fractions
    of the stake (M03): f* = p/loss - (1-p)/gain. Return 0 if the edge is non-positive.
    Raise ValueError unless gain > 0 and loss > 0."""
    raise NotImplementedError


def spread_series(p_target: np.ndarray, p_acquirer: np.ndarray, ratio: float) -> np.ndarray:
    """Percentage spread time series (R * P_A - P_T) / P_T for aligned price arrays."""
    raise NotImplementedError


def years_between(d0: str, d1: str) -> float:
    """Actual/365 year fraction between ISO dates (helper; provided)."""
    import datetime as dt
    a = dt.date.fromisoformat(d0)
    b = dt.date.fromisoformat(d1)
    return (b - a).days / 365.0

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.