src/lib/pricing/__tests__/pricing.test.ts

238 lines
import { describe, expect, it } from "vitest";
import {
  brent,
  carriedGrossDividends,
  DAYS_PER_YEAR,
  expectedAccruedFunding,
  expiryFromLabel,
  fairSyntheticPrice,
  fairTRFBasis,
  fairTRFSpread,
  forwardFromTRF,
  overnightRate360,
  addWeekdays,
  settlementDays,
  settlementFraction,
  trfSchedule,
  trfClearingPrice,
  implied,
  premiumDiscountFactor,
  priceForward,
  thirdFriday,
  utc,
  yearFraction,
  type ForwardArgs,
} from "..";

const base: ForwardArgs = {
  spot: 5400,
  t: 2.35,
  rate: 0.021,
  fundingSpread: 0.0062,
  dividends: [
    { t: 0.1, gross: 0.33 },
    { t: 0.35, gross: 29.92 },
    { t: 1.35, gross: 188 },
    { t: 2.35, gross: 191 },
    { t: 3.35, gross: 186.75 }, // after expiry: must be ignored
  ],
  allIn: 0.87,
  divScale: 1,
  eqlSpread: 0.005,
};

describe("time", () => {
  it("uses an ACT/360 year", () => {
    expect(DAYS_PER_YEAR).toBe(360);
    expect(yearFraction(utc(2026, 0, 1), utc(2027, 0, 1))).toBeCloseTo(365 / 360, 15);
  });
  it("finds third Fridays", () => {
    expect(thirdFriday(2026, 11).toISOString().slice(0, 10)).toBe("2026-12-18");
    expect(expiryFromLabel("Dec27").date.toISOString().slice(0, 10)).toBe("2027-12-17");
  });
});

describe("priceForward", () => {
  it("matches S·e^{Rt} with no dividends", () => {
    const a = { ...base, dividends: [] };
    expect(priceForward(a)).toBeCloseTo(5400 * Math.exp(0.0272 * 2.35), 10);
  });
  it("with settlement: S carried settle-to-settle at R, divs carried pay-to-settle at €STR", () => {
    const settle = { spot: 2 / DAYS_PER_YEAR, expiry: base.t + 4 / DAYS_PER_YEAR };
    const R = base.rate + base.fundingSpread;
    const fv = 0.87 * [0.33, 29.92, 188, 191].reduce(
      (acc, d, i) => acc + d * Math.exp(base.rate * (settle.expiry - [0.1, 0.35, 1.35, 2.35][i])),
      0,
    );
    const manual = 5400 * Math.exp(R * (settle.expiry - settle.spot)) - fv;
    expect(priceForward({ ...base, settle })).toBeCloseTo(manual, 10);
  });

  it("ignores dividends after expiry", () => {
    const r = base.rate;
    const manual =
      0.33 * Math.exp(r * 2.25) + 29.92 * Math.exp(r * 2.0) + 188 * Math.exp(r * 1.0) + 191;
    expect(carriedGrossDividends(base)).toBeCloseTo(manual, 10);
  });
});

describe("synthetic (switch vs spot)", () => {
  it("is F·e^{−(r+EQL)t} − S", () => {
    const df = Math.exp(-(0.021 + 0.005) * 2.35);
    expect(fairSyntheticPrice(base)).toBeCloseTo(priceForward(base) * df - 5400, 10);
  });

  it("is zero with no dividends when funding = EQL", () => {
    const a = { ...base, dividends: [], fundingSpread: base.eqlSpread };
    expect(fairSyntheticPrice(a)).toBeCloseTo(0, 10);
  });

  it("is nearly spot-insensitive when funding ≈ EQL: ∂Q/∂S = e^{(s−EQL)t} − 1", () => {
    const bump = fairSyntheticPrice({ ...base, spot: 5401 }) - fairSyntheticPrice(base);
    expect(bump).toBeCloseTo(Math.exp((0.0062 - 0.005) * 2.35) - 1, 10);
  });
});

describe("TRF", () => {
  it("accrued funding: closed-form integral matches a daily sum of forwards", () => {
    const days = Math.round(base.t * DAYS_PER_YEAR);
    const a = { ...base, t: days / DAYS_PER_YEAR };
    let sum = 0;
    for (let d = 0; d < days; d++) sum += priceForward({ ...a, t: d / DAYS_PER_YEAR });
    const daily = (overnightRate360(a.rate) / 360) * sum;
    // continuous integral vs left Riemann sum: agree to well under 0.1 index point
    expect(Math.abs(expectedAccruedFunding(a) - daily)).toBeLessThan(0.1);
  });

  it("inverse: forwardFromTRF at the fair spread recovers the forward", () => {
    expect(forwardFromTRF(base, fairTRFSpread(base))).toBeCloseTo(priceForward(base), 8);
  });

  it("is ~0 with no dividends and no funding spread", () => {
    expect(fairTRFSpread({ ...base, dividends: [], fundingSpread: 0 })).toBeCloseTo(0, 1);
  });

  it("no dividends: funding spread restated ACT/360, compounded on the growing index", () => {
    // Basis = S·(e^{Rt} − 1)·s/R, quoted simple on today's index: TRF = s·(360/Y)·(e^{Rt} − 1)/(Rt)
    const R = base.rate + 0.01;
    const trf = fairTRFSpread({ ...base, dividends: [], fundingSpread: 0.01 });
    expect(trf).toBeCloseTo(100 * (360 / DAYS_PER_YEAR) * (Math.expm1(R * base.t) / (R * base.t)), 1);
  });

  it("passes (1 − allIn) of dividends through to the basis", () => {
    const d = (allIn: number) => fairTRFBasis({ ...base, allIn, divScale: 1.01 }) - fairTRFBasis({ ...base, allIn });
    const gross = 0.01 * (0.33 + 29.92 + 188 + 191);
    expect(d(0.87)).toBeGreaterThan(0.1 * gross); // ≈ 13% of the bump, less carry
    expect(Math.abs(d(1))).toBeLessThan(0.05 * gross); // full pass-through: only carry remains
  });
});

describe("settlement (2 weekdays, no holidays)", () => {
  const iso = (d: Date) => d.toISOString().slice(0, 10);
  it("skips weekends", () => {
    expect(iso(addWeekdays(utc(2026, 7, 11), 2))).toBe("2026-08-13"); // Tue → Thu
    expect(iso(addWeekdays(utc(2026, 11, 18), 2))).toBe("2026-12-22"); // Fri → Tue
  });
  it("Dec26 basis runs Thu 13 Aug → Tue 22 Dec: 131 days", () => {
    expect(settlementDays(utc(2026, 7, 11), utc(2026, 11, 18))).toBe(131);
  });
  it("schedule: one step per business day, deltas sum to the basis day count", () => {
    const s = trfSchedule(utc(2026, 7, 11), utc(2026, 11, 18));
    expect(s.steps).toHaveLength(93); // weekdays in (11 Aug, 18 Dec]: 18 weeks + Wed–Fri
    expect(s.steps.reduce((a, x) => a + x.delta, 0)).toBeCloseTo(s.basisDelta, 12);
    // weekends accrue on the step whose settlement window spans them (Wed → Thu settles Fri → Mon)
    expect(Math.max(...s.steps.map((x) => x.delta * 360))).toBe(3);
  });
  it("discrete funding sum ≈ the continuous integral", () => {
    const val = utc(2026, 7, 11);
    const exp = utc(2027, 11, 17);
    const a = { ...base, t: yearFraction(val, exp), dividends: [] };
    const discrete = expectedAccruedFunding({ ...a, trf: trfSchedule(val, exp) });
    // the schedule is shifted by settlement (+0 to +2 days over the whole period): within ~0.5%
    expect(discrete / expectedAccruedFunding(a)).toBeCloseTo(1, 2);
  });
});

describe("TRF clearing notation vs Eurex Circular 086/21, Attachment 3, appendix (18 Sep 2020)", () => {
  // TRF_t = Accrual + S·(1 + Y·Δ_SSP), weekends-only settlement calendar
  const S = 3283.69;
  const accrual = 490.96;
  const val = utc(2020, 8, 18);
  const rows: [string, number, number][] = [
    ["2020-12-18", -6.5, 3774.11], ["2021-03-19", -0.5, 3774.57], ["2021-06-18", 25.0, 3780.88],
    ["2021-09-17", 21.0, 3781.63], ["2021-12-17", 23.0, 3784.2], ["2022-03-18", 26.5, 3787.85],
    ["2022-06-17", 34.5, 3794.7], ["2022-09-16", 32.5, 3796.23], ["2022-12-16", 35.0, 3800.8],
    ["2023-03-17", 41.0, 3808.68], ["2023-06-16", 46.0, 3816.65], ["2023-09-15", 47.0, 3821.47],
    ["2023-12-15", 46.0, 3824.29], ["2024-03-15", 50.0, 3832.76], ["2024-06-21", 54.0, 3842.23],
    ["2024-09-20", 51.0, 3842.71], ["2024-12-20", 55.0, 3852.61], ["2025-03-21", 60.0, 3864.68],
    ["2025-06-20", 59.0, 3868.08], ["2025-09-19", 61.0, 3876.31], ["2025-12-19", 63.0, 3884.87],
    ["2026-12-18", 70.0, 3920.36], ["2027-12-17", 79.5, 3966.53], ["2028-12-15", 83.5, 4003.9],
  ];
  const delta = (iso: string) => settlementFraction(val, new Date(iso + "T00:00:00Z"));

  it.each(rows)("%s: spread %d bp → %d", (iso, spread, price) => {
    expect(Math.abs(trfClearingPrice(S, accrual, spread, delta(iso)) - price)).toBeLessThan(0.01);
  });

  it("DEC29 is off by exactly the 2 Christmas holidays (no TARGET2 calendar)", () => {
    const diff = 4053.91 - trfClearingPrice(S, accrual, 90.5, delta("2029-12-21"));
    expect(diff).toBeCloseTo(S * 90.5e-4 * (2 / 360), 2);
  });
});

describe("brent", () => {
  it("solves a cubic", () => {
    expect(brent((x) => x ** 3 - 2 * x - 5, 2, 3)).toBeCloseTo(2.0945514815423265, 12);
  });
});

describe("implied — Brent vs closed form (synthetic quote)", () => {
  const Q = fairSyntheticPrice(base);
  const df = premiumDiscountFactor(base);
  const R = base.rate + base.fundingSpread;
  const A = base.spot * Math.exp(R * base.t);
  const D = carriedGrossDividends(base);

  it("funding spread, no dividends: s = ln((Q+S)/(DF·S))/t − r", () => {
    const a = { ...base, dividends: [] };
    const Qa = fairSyntheticPrice(a);
    const closed = Math.log((Qa + a.spot) / (df * a.spot)) / a.t - a.rate;
    expect(implied(fairSyntheticPrice, "fundingSpread", Qa, { ...a, fundingSpread: 0 })).toBeCloseTo(closed, 12);
  });

  it("all-in is linear: allIn = (A − (Q+S)/DF) / (k·ΣD̃)", () => {
    const target = Q - 10;
    const closed = (A - (target + base.spot) / df) / (base.divScale * D);
    expect(implied(fairSyntheticPrice, "allIn", target, base)).toBeCloseTo(closed, 12);
  });

  it("dividend scale is linear", () => {
    const target = Q + 15;
    const closed = (A - (target + base.spot) / df) / (base.allIn * D);
    expect(implied(fairSyntheticPrice, "divScale", target, base)).toBeCloseTo(closed, 12);
  });

  it("spot is linear but ill-conditioned: S = (Q + DF·allIn·k·ΣD̃) / (DF·e^{Rt} − 1)", () => {
    const target = Q + 0.5;
    const closed = (target + df * base.allIn * base.divScale * D) / (df * Math.exp(R * base.t) - 1);
    expect(implied(fairSyntheticPrice, "spot", target, base)).toBeCloseTo(closed, 6);
  });

  it("round-trips every solvable key through the synthetic", () => {
    for (const key of ["rate", "fundingSpread", "allIn", "divScale", "eqlSpread"] as const) {
      const x = implied(fairSyntheticPrice, key, Q, { ...base, [key]: base[key] * 1.3 + 0.001 });
      expect(x).toBeCloseTo(base[key], 10);
    }
  });

  it("round-trips funding through the TRF", () => {
    const quote = fairTRFSpread(base);
    expect(implied(fairTRFSpread, "fundingSpread", quote, { ...base, fundingSpread: 0 })).toBeCloseTo(0.0062, 12);
  });

  it("widens the bracket when the root is outside", () => {
    const quote = fairTRFSpread({ ...base, fundingSpread: 0.35 });
    expect(implied(fairTRFSpread, "fundingSpread", quote, base)).toBeCloseTo(0.35, 10);
  });
});