Skip to content

Commit 9530ab6

Browse files
committed
QRE-124: TotalReturnSwap — equity TRS with return leg, funding leg, periodic coupon schedule
1 parent 9ab078e commit 9530ab6

5 files changed

Lines changed: 472 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
![Python](https://img.shields.io/badge/Python-3.13-blue?logo=python&logoColor=white)
22
![QuantLib](https://img.shields.io/badge/QuantLib-1.42.1-orange)
33
![Tests](https://github.com/mrspatbile/quant-risk-engine/actions/workflows/test.yml/badge.svg)
4-
![Tests passing](https://img.shields.io/badge/tests-435%20passing-brightgreen)
4+
![Tests passing](https://img.shields.io/badge/tests-454%20passing-brightgreen)
55

66
# Quant Risk Engine
77

@@ -222,7 +222,7 @@ src/quant_risk/
222222
**Design principles:** abstract base classes, dependency injection, no global state,
223223
QuantLib global clock managed via context manager.
224224

225-
**Tests:** 435 passing, 1 skipped — no live API calls, all fixtures synthetic.
225+
**Tests:** 454 passing, 1 skipped — no live API calls, all fixtures synthetic.
226226

227227
---
228228

src/quant_risk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Decumulator,
2222
WorstOfOption,
2323
BestOfOption,
24+
TotalReturnSwap,
2425
)
2526
from quant_risk.curves import OISCurve, NSSCurve, ArrayCurve
2627
from quant_risk.models import (
@@ -59,6 +60,7 @@
5960
"Decumulator",
6061
"WorstOfOption",
6162
"BestOfOption",
63+
"TotalReturnSwap",
6264
# curves
6365
"OISCurve",
6466
"NSSCurve",

src/quant_risk/instruments/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
Decumulator,
2626
)
2727
from quant_risk.instruments.multi_asset import WorstOfOption, BestOfOption
28+
from quant_risk.instruments.trs import TotalReturnSwap
2829

2930
__all__ = [
3031
"Bond",
@@ -49,4 +50,5 @@
4950
"Decumulator",
5051
"WorstOfOption",
5152
"BestOfOption",
53+
"TotalReturnSwap",
5254
]

src/quant_risk/instruments/trs.py

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
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

Comments
 (0)