/**
* TRF carry and roll-down monitor.
*
* Position: a calendar spread of notional N between two tenors — by default long the far
* TRF and short the near one, i.e. paying the forward TRF spread from the near expiry to the
* far one. A TRF's P&L for a change in its spread Y is N · ΔY · Δ(t,T) (the basis is
* S · Y · Δ and N = contracts · multiplier · S), with Δ the settlement-adjusted ACT/360
* fraction the TRF is quoted on.
*
* Curve: one TRF spread per tenor, interpolated linearly in Y·Δ (piecewise-flat forward
* spreads), flat before the first tenor.
*
* Horizon h — by default the near tenor's expiry, when the calendar spread ends (after it,
* only an outright far TRF is left) — with the curve static in maturity:
* carry each leg's basis decays over the time that passes: a long pays Y · elapsed,
* a short receives it (a leg expiring inside h only carries to its expiry)
* roll-down each live leg re-marks to today's curve at its shorter maturity Δ′:
* ± N · (Y(Δ′) − Y) · Δ′
* forward f = (Y_far·Δ_far − Y_near·Δ_near) / (Δ_far − Δ_near)
* fwd DV01 N · (Δ_far − Δ_near) · 1bp — P&L for a 1bp move in f
*/
export interface CurvePoint {
label: string;
/** Δ_SSP(valuation, expiry): ACT/360 with settlement lags. */
delta: number;
/** Δ_SSP(valuation + horizon, expiry), or 0 if the tenor expires within the horizon. */
deltaAfter: number;
/** TRF spread, bp. */
spread: number;
}
const BP = 1e-4;
/** TRF spread for any maturity Δ, linear in Y·Δ between tenors, flat before the first. */
export function curveSpread(curve: CurvePoint[], delta: number): number {
const pts = [...curve].sort((a, b) => a.delta - b.delta);
if (pts.length === 0) throw new Error("Empty curve");
if (delta <= pts[0].delta) return pts[0].spread;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
if (delta <= b.delta) {
const w = (delta - a.delta) / (b.delta - a.delta);
const total = a.spread * a.delta + w * (b.spread * b.delta - a.spread * a.delta);
return total / delta;
}
}
const last = pts[pts.length - 1];
return last.spread; // beyond the last tenor: flat (never needed for roll-down)
}
/**
* Forward between two tenors on any per-tenor curve (e.g. term funding), today and at the
* horizon: before the near expiry it is the forward of the rolled curve; once the near tenor
* has expired it is the curve at the far tenor's remaining maturity.
*/
export function forwardOnCurve(curve: CurvePoint[], near: string, far: string): { today: number; atHorizon: number } {
const n = curve.find((c) => c.label === near);
const f = curve.find((c) => c.label === far);
if (!n || !f) throw new Error("Unknown tenor");
if (f.delta <= n.delta) throw new Error("The far tenor must be after the near tenor");
const today = forwardSpread(n.spread, n.delta, f.spread, f.delta);
const atHorizon =
n.deltaAfter > 0
? forwardSpread(curveSpread(curve, n.deltaAfter), n.deltaAfter, curveSpread(curve, f.deltaAfter), f.deltaAfter)
: curveSpread(curve, f.deltaAfter);
return { today, atHorizon };
}
/** Annualizes an amount earned over `days` on Act/360. */
export const annualize = (amount: number, days: number) => (amount * 360) / days;
/** Forward TRF spread between two maturities, bp. */
export const forwardSpread = (y1: number, d1: number, y2: number, d2: number) => (y2 * d2 - y1 * d1) / (d2 - d1);
export interface LegResult {
label: string;
/** +1 long, −1 short. */
sign: number;
spread: number;
/** Spread at the rolled maturity (null if the leg expires within the horizon). */
rolledSpread: number | null;
carry: number;
rollDown: number;
}
export interface CarryRollResult {
legs: [near: LegResult, far: LegResult];
carry: number;
rollDown: number;
total: number;
forwardSpread: number;
/**
* Forward spread of the same calendar after the horizon. Once the near leg has expired the
* forward period has started, so this is the far leg's spread at its remaining maturity.
*/
rolledForwardSpread: number | null;
/** EUR per 1bp of the forward spread (positive: the position gains when it rises). */
forwardDV01: number;
}
function leg(curve: CurvePoint[], p: CurvePoint, sign: number, notional: number): LegResult {
const live = p.deltaAfter > 0;
const elapsed = p.delta - Math.max(p.deltaAfter, 0);
const rolledSpread = live ? curveSpread(curve, p.deltaAfter) : null;
return {
label: p.label,
sign,
spread: p.spread,
rolledSpread,
carry: -sign * notional * p.spread * BP * elapsed,
rollDown: live ? sign * notional * (rolledSpread! - p.spread) * BP * p.deltaAfter : 0,
};
}
/**
* Expected carry and roll-down over the horizon, and the forward DV01, for a calendar
* spread of `notional` between `near` and `far`. `longFar` = long far / short near.
*/
export function carryRoll(p: {
curve: CurvePoint[];
near: string;
far: string;
notional: number;
longFar: boolean;
}): CarryRollResult {
const near = p.curve.find((c) => c.label === p.near);
const far = p.curve.find((c) => c.label === p.far);
if (!near || !far) throw new Error("Unknown tenor");
if (far.delta <= near.delta) throw new Error("The far tenor must be after the near tenor");
const s = p.longFar ? 1 : -1;
const legs: [LegResult, LegResult] = [leg(p.curve, near, -s, p.notional), leg(p.curve, far, s, p.notional)];
const carry = legs[0].carry + legs[1].carry;
const rollDown = legs[0].rollDown + legs[1].rollDown;
return {
legs,
carry,
rollDown,
total: carry + rollDown,
forwardSpread: forwardSpread(near.spread, near.delta, far.spread, far.delta),
rolledForwardSpread:
near.deltaAfter > 0
? forwardSpread(legs[0].rolledSpread!, near.deltaAfter, legs[1].rolledSpread!, far.deltaAfter)
: legs[1].rolledSpread,
forwardDV01: s * p.notional * (far.delta - near.delta) * BP,
};
}