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

61 lines
import { describe, expect, it } from "vitest";
import { initialTable, twoWay } from "../../grid/rowModel";
import { DEFAULT_ALL_IN, SX5E_MARKET, SX5E_TRF_RUN } from "../../market/sx5e";
import { settlementFraction } from "../../pricing";
import { positionGreeks } from "../greeks";

const N = 100_000_000;
const quotes = SX5E_TRF_RUN.map((q) => ({ expiry: q.expiry, trf: twoWay(q.bid, q.offer) }));

describe("calendar spread greeks", () => {
  it("without dividends: delta is the basis difference, IR DV01 is second order", () => {
    const m = { ...SX5E_MARKET, dividends: [] };
    const rows = initialTable(m, quotes, DEFAULT_ALL_IN);
    const g = positionGreeks({ market: m, rows, near: "Dec27", far: "Dec28", notional: N, longFar: false });
    const d = (i: number) => settlementFraction(m.valuationDate, SX5E_TRF_RUN[i].expiry.date);
    const y = (i: number) => (rows[i].trf.bid + rows[i].trf.ask) / 2;
    // receive: long near (Dec27), short far (Dec28); 1% of each leg's S·(1 + YΔ)
    const expected = 0.01 * N * ((1 + y(1) * 1e-4 * d(1)) - (1 + y(2) * 1e-4 * d(2)));
    expect(g.delta1pct).toBeCloseTo(expected, 0);
    // second order only (the hedge compounds at €STR + s, the TRF funding leg accrues at €STR):
    // a few % of the ~10,111 EUR/bp forward funding DV01
    expect(Math.abs(g.irDV01)).toBeLessThan(0.03 * N * 1e-4);
  });

  it("forward funding DV01 is bump-based: ≈ N·(Δ_far − Δ_near)·1bp × ∂TRF/∂funding", () => {
    const rows = initialTable(SX5E_MARKET, quotes, DEFAULT_ALL_IN);
    const rec = positionGreeks({ market: SX5E_MARKET, rows, near: "Dec26", far: "Dec27", notional: N, longFar: false });
    const pay = positionGreeks({ market: SX5E_MARKET, rows, near: "Dec26", far: "Dec27", notional: N, longFar: true });
    const d = (i: number) => settlementFraction(SX5E_MARKET.valuationDate, SX5E_TRF_RUN[i].expiry.date);
    const analytic = N * 1e-4 * (d(1) - d(0)); // 10,111
    // receiving loses when forward funding rises; the TRF moves ~1.7% more than funding at Dec27
    expect(rec.forwardFundingDV01).toBeLessThan(-analytic);
    expect(rec.forwardFundingDV01 / -analytic).toBeCloseTo(1.017, 2);
    expect(pay.forwardFundingDV01).toBeCloseTo(-rec.forwardFundingDV01, 6);
  });

  it("legs are mirror images when the position is reversed", () => {
    const rows = initialTable(SX5E_MARKET, quotes, DEFAULT_ALL_IN);
    const rec = positionGreeks({ market: SX5E_MARKET, rows, near: "Dec26", far: "Dec27", notional: N, longFar: false });
    const pay = positionGreeks({ market: SX5E_MARKET, rows, near: "Dec26", far: "Dec27", notional: N, longFar: true });
    expect(pay.delta1pct).toBeCloseTo(-rec.delta1pct, 6);
    expect(pay.irDV01).toBeCloseTo(-rec.irDV01, 6);
  });

  it("with dividends in points, the pass-through makes TRF spreads fall as spot rises", () => {
    const rows = initialTable(SX5E_MARKET, quotes, DEFAULT_ALL_IN);
    const g = positionGreeks({ market: SX5E_MARKET, rows, near: "Dec27", far: "Dec28", notional: N, longFar: false });
    const noDivM = { ...SX5E_MARKET, dividends: [] };
    const noDiv = positionGreeks({
      market: noDivM,
      rows: initialTable(noDivM, quotes, DEFAULT_ALL_IN),
      near: "Dec27",
      far: "Dec28",
      notional: N,
      longFar: false,
    });
    // short the longer tenor, whose pass-through (and its fall with spot) is larger: extra positive delta
    expect(g.delta1pct).toBeGreaterThan(noDiv.delta1pct);
  });
});