src/lib/carry/carry.ts

150 lines
/**
 * 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,
  };
}