|
| 1 | +""" |
| 2 | +TotalReturnSwap — equity total return swap. |
| 3 | +
|
| 4 | +The asset receiver: |
| 5 | + — receives the total return on the reference asset (price + dividends) |
| 6 | + — pays OIS + funding spread on the notional |
| 7 | +
|
| 8 | +Pricing model |
| 9 | +------------- |
| 10 | +Return leg NPV: |
| 11 | + PV of receiving the asset at maturity, in units of notional: |
| 12 | + notional × (S_current × exp(-q × T) / S_inception − df(T)) |
| 13 | +
|
| 14 | + where: |
| 15 | + S_current — reference asset price today |
| 16 | + S_inception — reference asset price at trade inception (the "strike") |
| 17 | + q — continuous dividend yield |
| 18 | + T — remaining life in years |
| 19 | + df(T) — OIS discount factor to maturity |
| 20 | +
|
| 21 | + At inception (S_current = S_inception, q = 0): |
| 22 | + return_leg_npv ≈ notional × (1 − df(T)) |
| 23 | +
|
| 24 | +Funding leg NPV: |
| 25 | + Sum of OIS + spread coupons at each payment date, discounted: |
| 26 | + notional × Σᵢ (r_i + spread) × Δtᵢ × df(tᵢ) |
| 27 | +
|
| 28 | + where r_i is the continuously compounded zero rate to payment date tᵢ. |
| 29 | +
|
| 30 | +NPV (to asset receiver): |
| 31 | + return_leg_npv − funding_leg_npv |
| 32 | +
|
| 33 | +Rate convention : decimal throughout. funding_spread_bps is converted to |
| 34 | + decimal internally (÷ 10_000). |
| 35 | +Dates : ISO strings at public boundaries; ql.Date internally. |
| 36 | +Notional : currency units, no implicit scaling. |
| 37 | +""" |
| 38 | + |
| 39 | +import numpy as np |
| 40 | +import pandas as pd |
| 41 | +import QuantLib as ql |
| 42 | + |
| 43 | +from quant_risk.curves.base import DiscountCurve |
| 44 | +from quant_risk.instruments.base import Instrument |
| 45 | + |
| 46 | + |
| 47 | +class TotalReturnSwap(Instrument): |
| 48 | + """ |
| 49 | + Equity total return swap (TRS). |
| 50 | +
|
| 51 | + The asset receiver receives the total return on the reference asset |
| 52 | + (price appreciation + continuous dividend yield) and pays OIS + spread. |
| 53 | +
|
| 54 | + Parameters |
| 55 | + ---------- |
| 56 | + notional_ : float |
| 57 | + Notional in currency units. |
| 58 | + spot_inception : float |
| 59 | + Reference asset price at trade inception — acts as the "strike". |
| 60 | + spot_current : float |
| 61 | + Current reference asset price used for mark-to-market. At inception, |
| 62 | + set equal to spot_inception (NPV ≈ 0 with zero spread). |
| 63 | + valuation_date : str |
| 64 | + Pricing date as ISO string 'YYYY-MM-DD'. |
| 65 | + maturity_date : str |
| 66 | + Trade maturity as ISO string 'YYYY-MM-DD'. |
| 67 | + funding_spread_bps : float |
| 68 | + Spread over OIS paid by the asset receiver on the funding leg, in bps. |
| 69 | + Positive = receiver pays above OIS. Negative = receiver funded below OIS. |
| 70 | + div_yield : float |
| 71 | + Continuous dividend yield of the reference asset, decimal. Default 0. |
| 72 | + long_asset : bool |
| 73 | + True = asset receiver (receive return, pay funding). Default True. |
| 74 | + False = asset payer (pay return, receive funding). |
| 75 | + payment_freq : int |
| 76 | + Funding leg payment frequency per year: 1=annual, 2=semi, 4=quarterly. |
| 77 | + Default 4. |
| 78 | + currency_ : str |
| 79 | + ISO 4217 currency code. Default 'EUR'. |
| 80 | + """ |
| 81 | + |
| 82 | + _day_count = ql.Actual365Fixed() |
| 83 | + |
| 84 | + def __init__( |
| 85 | + self, |
| 86 | + notional_: float, |
| 87 | + spot_inception: float, |
| 88 | + spot_current: float, |
| 89 | + valuation_date: str, |
| 90 | + maturity_date: str, |
| 91 | + funding_spread_bps: float, |
| 92 | + div_yield: float = 0.0, |
| 93 | + long_asset: bool = True, |
| 94 | + payment_freq: int = 4, |
| 95 | + currency_: str = 'EUR', |
| 96 | + ): |
| 97 | + self._notional_ = notional_ |
| 98 | + self._spot_inception = spot_inception |
| 99 | + self._spot_current = spot_current |
| 100 | + self._valuation_date_str = valuation_date |
| 101 | + self._maturity_date_str = maturity_date |
| 102 | + self._funding_spread = funding_spread_bps / 10_000 |
| 103 | + self._div_yield = div_yield |
| 104 | + self._long_asset = long_asset |
| 105 | + self._payment_freq = payment_freq |
| 106 | + self._currency_ = currency_ |
| 107 | + self._sign = 1.0 if long_asset else -1.0 |
| 108 | + |
| 109 | + self._val_date = self._parse_date(valuation_date) |
| 110 | + self._mat_date = self._parse_date(maturity_date) |
| 111 | + self._T = self._day_count.yearFraction(self._val_date, self._mat_date) |
| 112 | + |
| 113 | + self._validate() |
| 114 | + |
| 115 | + def _validate(self) -> None: |
| 116 | + if self._T <= 0: |
| 117 | + raise ValueError("maturity_date must be after valuation_date") |
| 118 | + if self._spot_inception <= 0: |
| 119 | + raise ValueError(f"spot_inception must be positive, got {self._spot_inception}") |
| 120 | + if self._spot_current <= 0: |
| 121 | + raise ValueError(f"spot_current must be positive, got {self._spot_current}") |
| 122 | + if self._payment_freq not in (1, 2, 4, 12): |
| 123 | + raise ValueError(f"payment_freq must be 1, 2, 4 or 12, got {self._payment_freq}") |
| 124 | + |
| 125 | + # ── internal schedule ───────────────────────────────────────────────────── |
| 126 | + |
| 127 | + def _payment_times(self) -> list[float]: |
| 128 | + """ |
| 129 | + List of time-in-years from valuation date to each funding coupon date. |
| 130 | + The final payment coincides with maturity. |
| 131 | + """ |
| 132 | + months_per_period = 12 // self._payment_freq |
| 133 | + times = [] |
| 134 | + cursor = self._val_date |
| 135 | + while True: |
| 136 | + cursor = cursor + ql.Period(months_per_period, ql.Months) |
| 137 | + t = self._day_count.yearFraction(self._val_date, cursor) |
| 138 | + if t >= self._T - 1e-6: |
| 139 | + times.append(self._T) |
| 140 | + break |
| 141 | + times.append(t) |
| 142 | + return times |
| 143 | + |
| 144 | + # ── pricing ─────────────────────────────────────────────────────────────── |
| 145 | + |
| 146 | + def _compute(self, curve: DiscountCurve) -> dict: |
| 147 | + df_T = curve.discount(self._T) |
| 148 | + |
| 149 | + # Return leg: PV of receiving asset total return to maturity |
| 150 | + return_leg_npv = self._notional_ * ( |
| 151 | + self._spot_current * np.exp(-self._div_yield * self._T) / self._spot_inception |
| 152 | + - df_T |
| 153 | + ) |
| 154 | + |
| 155 | + # Funding leg: periodic OIS + spread coupons |
| 156 | + payment_times = self._payment_times() |
| 157 | + funding_leg_npv = 0.0 |
| 158 | + t_prev = 0.0 |
| 159 | + for t_i in payment_times: |
| 160 | + dt = t_i - t_prev |
| 161 | + df_i = curve.discount(t_i) |
| 162 | + r_i = -np.log(df_i) / t_i if t_i > 0 else 0.0 |
| 163 | + coupon = self._notional_ * (r_i + self._funding_spread) * dt |
| 164 | + funding_leg_npv += coupon * df_i |
| 165 | + t_prev = t_i |
| 166 | + |
| 167 | + npv = self._sign * (return_leg_npv - funding_leg_npv) |
| 168 | + return { |
| 169 | + 'return_leg_npv': return_leg_npv, |
| 170 | + 'funding_leg_npv': funding_leg_npv, |
| 171 | + 'npv': npv, |
| 172 | + } |
| 173 | + |
| 174 | + def price(self, curve: DiscountCurve) -> dict: |
| 175 | + """ |
| 176 | + Full TRS valuation. |
| 177 | +
|
| 178 | + Parameters |
| 179 | + ---------- |
| 180 | + curve : DiscountCurve |
| 181 | + OIS discount curve. |
| 182 | +
|
| 183 | + Returns |
| 184 | + ------- |
| 185 | + dict with keys: |
| 186 | + return_leg_npv : PV of asset total return leg (always from asset receiver view) |
| 187 | + funding_leg_npv : PV of OIS + spread funding payments (always from asset receiver view) |
| 188 | + npv : net NPV to the holder (positive = asset receiver profits) |
| 189 | + """ |
| 190 | + return self._compute(curve) |
| 191 | + |
| 192 | + def npv(self, curve: DiscountCurve) -> float: |
| 193 | + """Net NPV in currency units.""" |
| 194 | + return self.price(curve)['npv'] |
| 195 | + |
| 196 | + def dv01(self, curve: DiscountCurve, bump: float = 0.0001) -> float: |
| 197 | + """ |
| 198 | + Change in NPV for a 1bp parallel shift in the OIS curve. |
| 199 | +
|
| 200 | + Asset receivers have negative DV01 on the funding leg (higher rates |
| 201 | + increase the floating coupon cost) and positive DV01 on the return |
| 202 | + leg (higher rates raise the forward asset price). Net sign depends |
| 203 | + on the relative sizes. |
| 204 | +
|
| 205 | + Parameters |
| 206 | + ---------- |
| 207 | + curve : DiscountCurve |
| 208 | + Baseline discount curve. |
| 209 | + bump : float |
| 210 | + Rate bump in decimal. Default 0.0001 (1bp). |
| 211 | + """ |
| 212 | + # Compute analytically: extract flat rate, bump, recompute both legs |
| 213 | + df_T = curve.discount(self._T) |
| 214 | + r = -np.log(df_T) / self._T if self._T > 0 else 0.0 |
| 215 | + |
| 216 | + # Build a shifted version of each discount factor using a flat bump |
| 217 | + class _BumpedCurve: |
| 218 | + """Thin wrapper that shifts all discount factors by bump.""" |
| 219 | + def __init__(self, base, bump): |
| 220 | + self._base = base |
| 221 | + self._bump = bump |
| 222 | + def discount(self, T): |
| 223 | + df = self._base.discount(T) |
| 224 | + r = -np.log(df) / T if T > 0 else 0.0 |
| 225 | + return np.exp(-(r + self._bump) * T) |
| 226 | + |
| 227 | + bumped = _BumpedCurve(curve, bump) |
| 228 | + return self._compute(bumped)['npv'] - self._compute(curve)['npv'] |
| 229 | + |
| 230 | + def duration(self, curve: DiscountCurve) -> float: |
| 231 | + """ |
| 232 | + Weighted average maturity of the funding leg cash flows. |
| 233 | + Approximates the interest rate sensitivity duration. |
| 234 | + """ |
| 235 | + payment_times = self._payment_times() |
| 236 | + t_prev = 0.0 |
| 237 | + total_pv = 0.0 |
| 238 | + weighted = 0.0 |
| 239 | + for t_i in payment_times: |
| 240 | + dt = t_i - t_prev |
| 241 | + df_i = curve.discount(t_i) |
| 242 | + r_i = -np.log(df_i) / t_i if t_i > 0 else 0.0 |
| 243 | + pv = (r_i + self._funding_spread) * dt * df_i |
| 244 | + weighted += t_i * pv |
| 245 | + total_pv += pv |
| 246 | + t_prev = t_i |
| 247 | + return weighted / total_pv if total_pv > 0 else self._T |
| 248 | + |
| 249 | + def cash_flows(self) -> pd.DataFrame: |
| 250 | + """ |
| 251 | + Expected funding leg coupon schedule based on current OIS rates. |
| 252 | + Return leg cash flow shown as a single indicative amount at maturity. |
| 253 | + """ |
| 254 | + rows = [] |
| 255 | + # Return leg — single indicative at maturity |
| 256 | + indicative_return = ( |
| 257 | + self._spot_current / self._spot_inception - 1.0 + self._div_yield * self._T |
| 258 | + ) * self._notional_ |
| 259 | + rows.append({ |
| 260 | + 'date': self._mat_date.ISO(), |
| 261 | + 'amount': indicative_return, |
| 262 | + 'type': 'return leg (indicative at current spot)', |
| 263 | + }) |
| 264 | + # Funding leg — schedule without curve dependency for simplicity |
| 265 | + payment_times = self._payment_times() |
| 266 | + t_prev = 0.0 |
| 267 | + for t_i in payment_times: |
| 268 | + dt = t_i - t_prev |
| 269 | + rows.append({ |
| 270 | + 'date': (self._val_date + ql.Period(int(t_i * 365), ql.Days)).ISO(), |
| 271 | + 'amount': -self._notional_ * self._funding_spread * dt, |
| 272 | + 'type': 'funding spread coupon (OIS spread component)', |
| 273 | + }) |
| 274 | + t_prev = t_i |
| 275 | + return pd.DataFrame(rows) |
| 276 | + |
| 277 | + def rate_sensitivities( |
| 278 | + self, |
| 279 | + curve: DiscountCurve, |
| 280 | + tenors: list[float], |
| 281 | + bump: float = 0.0001, |
| 282 | + ) -> dict[float, float]: |
| 283 | + """ |
| 284 | + IR sensitivity concentrated at the nearest tenor to maturity. |
| 285 | +
|
| 286 | + TRS rate sensitivity arises primarily from the funding leg discount |
| 287 | + and the forward asset price, both dominated by the maturity tenor. |
| 288 | +
|
| 289 | + Parameters |
| 290 | + ---------- |
| 291 | + curve : DiscountCurve |
| 292 | + Baseline discount curve. |
| 293 | + tenors : list[float] |
| 294 | + Vertex maturities in years. |
| 295 | + bump : float |
| 296 | + Rate bump in decimal. Default 0.0001 (1bp). |
| 297 | +
|
| 298 | + Returns |
| 299 | + ------- |
| 300 | + dict[float, float] |
| 301 | + {tenor_years: sensitivity} in currency units per 1bp. |
| 302 | + """ |
| 303 | + dv01_total = self.dv01(curve, bump) |
| 304 | + nearest_idx = min(range(len(tenors)), key=lambda i: abs(tenors[i] - self._T)) |
| 305 | + return {t: (dv01_total if i == nearest_idx else 0.0) for i, t in enumerate(tenors)} |
| 306 | + |
| 307 | + # ── Instrument ABC ──────────────────────────────────────────────────────── |
| 308 | + |
| 309 | + @property |
| 310 | + def currency(self) -> str: |
| 311 | + return self._currency_ |
| 312 | + |
| 313 | + @property |
| 314 | + def notional(self) -> float: |
| 315 | + return self._notional_ |
| 316 | + |
| 317 | + def describe(self) -> str: |
| 318 | + side = 'Asset Receiver' if self._long_asset else 'Asset Payer' |
| 319 | + return ( |
| 320 | + f"TotalReturnSwap | {side} | {self._currency_} | " |
| 321 | + f"Notional={self._notional_:,.0f} | " |
| 322 | + f"SpotInception={self._spot_inception:.2f} | " |
| 323 | + f"SpotCurrent={self._spot_current:.2f} | " |
| 324 | + f"Spread={self._funding_spread * 10_000:.0f}bps | " |
| 325 | + f"Maturity={self._maturity_date_str} | " |
| 326 | + f"DivYield={self._div_yield * 100:.2f}%" |
| 327 | + ) |
0 commit comments