src/lib/carry/__tests__/carry.test.ts

127 lines
import { describe, expect, it } from "vitest";
import { annualize, carryRoll, curveSpread, forwardSpread, type CurvePoint } from "../carry";

const N = 100_000_000;
/** A simple curve on round year fractions; horizon 1y. */
const pt = (label: string, delta: number, spread: number): CurvePoint => ({
  label,
  delta,
  deltaAfter: Math.max(delta - 1, 0),
  spread,
});

describe("curve", () => {
  const curve = [pt("A", 0.5, 60), pt("B", 1.5, 80), pt("C", 2.5, 90)];

  it("reproduces the tenors and is flat before the first", () => {
    expect(curveSpread(curve, 1.5)).toBeCloseTo(80, 12);
    expect(curveSpread(curve, 0.2)).toBe(60);
  });

  it("interpolates linearly in Y·Δ (flat forwards)", () => {
    // forward A→B = (80·1.5 − 60·0.5)/1 = 90; at Δ = 1: (60·0.5 + 90·0.5)/1 = 75
    expect(forwardSpread(60, 0.5, 80, 1.5)).toBeCloseTo(90, 12);
    expect(curveSpread(curve, 1)).toBeCloseTo(75, 12);
  });
});

describe("carry and roll-down", () => {
  it("a flat curve has no carry, no roll-down and a forward equal to the spot spread", () => {
    const curve = [pt("A", 1.5, 80), pt("B", 2.5, 80), pt("C", 3.5, 80)];
    const r = carryRoll({ curve, near: "A", far: "C", notional: N, longFar: true });
    expect(r.carry).toBeCloseTo(0, 6);
    expect(r.rollDown).toBeCloseTo(0, 6);
    expect(r.forwardSpread).toBeCloseTo(80, 12);
  });

  it("forward DV01 = N · (Δ_far − Δ_near) · 1bp, signed by direction", () => {
    const curve = [pt("A", 1.5, 70), pt("B", 2.5, 85)];
    expect(carryRoll({ curve, near: "A", far: "B", notional: N, longFar: true }).forwardDV01).toBeCloseTo(N * 1e-4, 6);
    expect(carryRoll({ curve, near: "A", far: "B", notional: N, longFar: false }).forwardDV01).toBeCloseTo(-N * 1e-4, 6);
  });

  it("paying the forward on an upward curve: negative carry and negative roll-down", () => {
    const curve = [pt("A", 0.5, 60), pt("B", 1.5, 80), pt("C", 2.5, 90)];
    const r = carryRoll({ curve, near: "B", far: "C", notional: N, longFar: true });
    // carry: short B receives 80·1y, long C pays 90·1y → −10bp · 1y · N
    expect(r.carry).toBeCloseTo(-10 * 1e-4 * N, 4);
    expect(r.rollDown).toBeLessThan(0);
    // the opposite trade mirrors it exactly
    const rev = carryRoll({ curve, near: "B", far: "C", notional: N, longFar: false });
    expect(rev.total).toBeCloseTo(-r.total, 4);
  });

  it("roll-down is the re-mark at the shorter maturity on today's curve", () => {
    const curve = [pt("A", 0.5, 60), pt("B", 1.5, 80), pt("C", 2.5, 90)];
    const r = carryRoll({ curve, near: "B", far: "C", notional: N, longFar: true });
    // far C rolls to Δ = 1.5 (spread 80): +N·(80 − 90)·1.5bp; near B rolls to Δ = 0.5 (60): −N·(60 − 80)·0.5bp
    expect(r.legs[1].rollDown).toBeCloseTo(N * (80 - 90) * 1e-4 * 1.5, 4);
    expect(r.legs[0].rollDown).toBeCloseTo(-N * (60 - 80) * 1e-4 * 0.5, 4);
    expect(r.rolledForwardSpread).toBeCloseTo(forwardSpread(60, 0.5, 80, 1.5), 10);
  });

  it("a near leg expiring inside the horizon only carries to expiry and does not roll", () => {
    const curve = [pt("A", 0.5, 60), pt("B", 1.5, 80)];
    const r = carryRoll({ curve, near: "A", far: "B", notional: N, longFar: true });
    expect(r.legs[0].rolledSpread).toBeNull();
    expect(r.legs[0].rollDown).toBe(0);
    expect(r.legs[0].carry).toBeCloseTo(N * 60 * 1e-4 * 0.5, 4); // short receives 60bp for its last 0.5y
    // the forward period has started: the forward is now the far leg's own spread
    expect(r.rolledForwardSpread).toBe(r.legs[1].rolledSpread);
  });

  it("rejects a far tenor that is not after the near one", () => {
    const curve = [pt("A", 0.5, 60), pt("B", 1.5, 80)];
    expect(() => carryRoll({ curve, near: "B", far: "A", notional: N, longFar: true })).toThrow();
  });
});

describe("carry + roll-down identity", () => {
  it("with both legs alive: carry + roll-down = forward DV01 × (rolled forward − forward)", () => {
    const curve = [pt("A", 0.5, 60), pt("B", 1.5, 80), pt("C", 2.5, 90), pt("D", 3.5, 95)];
    for (const [near, far] of [["B", "C"], ["B", "D"], ["C", "D"]] as const) {
      for (const longFar of [true, false]) {
        const r = carryRoll({ curve, near, far, notional: N, longFar });
        expect(r.total).toBeCloseTo(r.forwardDV01 * (r.rolledForwardSpread! - r.forwardSpread), 4);
      }
    }
  });

  it("carry alone is the slope of the curve times the elapsed time, not the forward level", () => {
    const curve = [pt("A", 0.5, 60), pt("B", 1.5, 80), pt("C", 2.5, 90)];
    const r = carryRoll({ curve, near: "B", far: "C", notional: N, longFar: true });
    expect(r.carry).toBeCloseTo(-N * (90 - 80) * 1e-4 * 1, 4); // −(Y_far − Y_near) · 1y
    expect(Math.abs(r.carry)).toBeLessThan(Math.abs(N * r.forwardSpread * 1e-4)); // ≠ forward × 1y
  });
});

describe("horizon at the near expiry (the calendar spread's life)", () => {
  // near expires exactly at the horizon: Δ′_near = 0, Δ′_far = Δ_far − Δ_near
  const at = (label: string, delta: number, spread: number, h: number): CurvePoint => ({
    label,
    delta,
    deltaAfter: Math.max(delta - h, 0),
    spread,
  });
  const h = 1.375;
  const curve = [at("Dec26", 0.364, 62.5, h), at("Dec27", 1.375, 84.25, h), at("Dec28", 2.386, 90.25, h)];

  it("near carries its full remaining spread, far rolls to the forward period's length", () => {
    const r = carryRoll({ curve, near: "Dec27", far: "Dec28", notional: N, longFar: true });
    expect(r.legs[0].carry).toBeCloseTo(N * 84.25 * 1e-4 * 1.375, 4);
    expect(r.legs[0].rollDown).toBe(0);
    expect(r.legs[1].carry).toBeCloseTo(-N * 90.25 * 1e-4 * 1.375, 4);
    expect(r.rolledForwardSpread).toBeCloseTo(curveSpread(curve, 2.386 - 1.375), 10);
  });

  it("carry + roll-down = forward DV01 × (forward at near expiry − forward today)", () => {
    const r = carryRoll({ curve, near: "Dec27", far: "Dec28", notional: N, longFar: true });
    expect(r.total).toBeCloseTo(r.forwardDV01 * (r.rolledForwardSpread! - r.forwardSpread), 4);
  });

  it("annualizes on Act/360", () => {
    expect(annualize(1000, 180)).toBeCloseTo(2000, 10);
    expect(annualize(1000, 360)).toBe(1000);
  });
});