src/lib/grid/__tests__/rowModel.test.ts

314 lines
import { describe, expect, it } from "vitest";
import { DEFAULT_ALL_IN, SX5E_MARKET as m, SX5E_TRF_RUN } from "../../market/sx5e";
import * as P from "../../pricing";
import { expiryFromLabel } from "../../pricing";
import {
  editRow,
  editTable,
  initialTable,
  marksByExpiry,
  setExpiryTable,
  solveTable,
  flat,
  initialRow,
  isCrossed,
  isLit,
  setExpiry,
  setSolveFor,
  setSolveForTable,
  solveRow,
  twoWay,
  type Quote,
  type RowState,
} from "../rowModel";

const dec27 = expiryFromLabel("Dec27");
const fresh = () => initialRow(m, dec27, twoWay(83.5, 85), DEFAULT_ALL_IN);

/** Fair-value state: funding, div and all-in lit, prices solved. */
const fairValue = (r: RowState = fresh(), fund: Quote = twoWay(80, 90)): RowState =>
  solveRow(m, { ...r, fund, dark: "price" });

/** Sets a two-way through the grid's edit path (marks are derived). */
const editQuote = (r: RowState, col: Parameters<typeof editRow>[2], bid: number, ask: number) =>
  editRow(m, editRow(m, r, col, "bid", bid), col, "ask", ask);

describe("row model — slots", () => {
  it("starts from the TRF run with funding solved, and reprices back to the run", () => {
    const r = fresh();
    expect(r.dark).toBe("fund");
    expect(r.priceKind).toBe("trf");
    // part of the TRF is the 13% dividend pass-through, so funding sits below it
    expect(r.fund.mark).toBeGreaterThan(0);
    expect(r.fund.ask).toBeLessThan(r.trf.bid);
    expect(r.div.mark).toBeCloseTo(0.33 + 30.25 + 218.25, 10); // cumulative to Dec27
    expect(isCrossed(r.fund)).toBe(false);

    const back = fairValue(r, r.fund);
    expect(back.dark).toBe("price");
    expect(back.trf.bid).toBeCloseTo(83.5, 8);
    expect(back.trf.ask).toBeCloseTo(85, 8);
    // the lit funding mark is the mid of the solved two-way, not the solved mark: tiny non-linearity
    expect(back.trf.mark).toBeCloseTo(84.25, 3);
  });

  it("Fwd, Synth and TRF are the same price: a lit TRF implies the forward and the switch", () => {
    const r = fresh();
    expect(isLit(r, "trf")).toBe(true);
    expect(isLit(r, "fwd") || isLit(r, "synth")).toBe(false);
    // quoting the implied forward back reproduces the TRF quote
    // (the forward's mark becomes its mid, so allow for that small non-linearity)
    const viaFwd = solveRow(m, { ...r, priceKind: "fwd" });
    expect(viaFwd.trf.mark).toBeCloseTo(84.25, 2);
    expect(viaFwd.fund.mark).toBeCloseTo(r.fund.mark, 2);
  });

  it("the solved slot is explicit: quoting Synth keeps solving funding, with Synth now lit", () => {
    const r0 = fresh();
    const r = editRow(m, r0, "synth", "bid", r0.synth.bid);
    expect(r.dark).toBe("fund");
    expect(isLit(r, "synth")).toBe(true);
    expect(isLit(r, "trf") || isLit(r, "fwd")).toBe(false);
  });

  it("solved cells are read-only: editing them is ignored", () => {
    const r = fresh();
    expect(editRow(m, r, "fund", "bid", 10)).toBe(r);
    const fv = fairValue();
    expect(editRow(m, fv, "trf", "bid", 10)).toBe(fv);
  });

  it("solve for prices: assumptions in, Fwd/Synth/TRF out", () => {
    const r = setSolveFor(m, fresh(), "price");
    expect(r.dark).toBe("price");
    expect(isLit(r, "trf") || isLit(r, "fwd") || isLit(r, "synth")).toBe(false);
    const wider = editRow(m, editRow(m, r, "fund", "bid", r.fund.bid - 5), "fund", "ask", r.fund.ask + 5);
    expect(wider.trf.ask - wider.trf.bid).toBeGreaterThan(r.trf.ask - r.trf.bid);
  });

  it("a TRF quote can solve dividends (via the 1 − allIn pass-through)", () => {
    const r = setSolveFor(m, fresh(), "div");
    expect(r.dark).toBe("div");
    expect(r.error).toBeUndefined();
    expect(isCrossed(r.div)).toBe(false);
  });
});

describe("row model — marks and two-way", () => {
  it("marks are fixed: bid/ask edits never move any mark", () => {
    const r0 = fresh();
    let r = editRow(m, r0, "fund", "bid", 70);
    r = editRow(m, r, "fund", "ask", 95);
    r = editRow(m, r, "div", "ask", r.div.bid * 1.2);
    r = editRow(m, r, "fwd", "bid", 6600);
    for (const c of ["fwd", "synth", "trf", "fund", "div", "divK", "allIn"] as const) {
      expect(r[c].mark).toBeCloseTo(r0[c].mark, 10);
    }
    // the mark layer is the run's set-up: TRF at mid, schedule dividends, 87% all-in
    expect(r.trf.mark).toBeCloseTo(84.25, 8);
    expect(r.divK.mark).toBe(1);
    expect(r.allIn.mark).toBe(0.87);
  });

  it("fair value: synth bid uses funding bid and dividend/all-in ask", () => {
    let r = fairValue();
    const divAsk = r.div.bid + 12;
    r = editRow(m, r, "div", "ask", divAsk);
    r = editRow(m, r, "allIn", "ask", 0.9);
    expect(r.dark).toBe("price");
    expect(isCrossed(r.synth)).toBe(false);

    const bidOnly = solveRow(m, { ...r, fund: flat(80), divK: flat(r.divK.ask), allIn: flat(0.9) });
    expect(r.synth.bid).toBeCloseTo(bidOnly.synth.bid, 8);
  });

  it("implied all-in from a synthetic quote crosses sides and is not crossed", () => {
    const fv = setSolveFor(m, fairValue(), "allIn");
    const ask = fv.synth.mark + 5;
    const r = editQuote(fv, "synth", fv.synth.mark - 5, ask); // Synth lit, solving all-in
    expect(r.dark).toBe("allIn");
    expect(isCrossed(r.allIn)).toBe(false);

    // the higher synth (ask) implies the lower all-in (bid), paired with funding bid and div ask
    const check = solveRow(m, { ...r, fund: flat(r.fund.bid), divK: flat(r.divK.ask), synth: flat(ask) });
    expect(r.allIn.bid).toBeCloseTo(check.allIn.bid, 8);
  });

  it("solved funding is about as wide as the TRF quote", () => {
    const r = editRow(m, fresh(), "trf", "ask", 87);
    expect((r.fund.ask - r.fund.bid) / (87 - 83.5)).toBeCloseTo(1, 1);
  });

  it("rolling expiry keeps each side's dividend scale (single row: one window)", () => {
    const dec27 = 0.33 + 30.25 + 218.25;
    const dec28 = dec27 + 409.25;
    const r = setExpiry(m, { ...fresh(), divK: { bid: 1, mark: 1, ask: 1.1 } }, expiryFromLabel("Dec28"));
    expect(dec27).toBeGreaterThan(0);
    expect(r.div.bid).toBeCloseTo(dec28, 8);
    expect(r.div.ask).toBeCloseTo(dec28 * 1.1, 8);
  });

  it("crossed means bid above ask; a mark outside bid/ask is fine (it is a reference)", () => {
    expect(isCrossed(twoWay(1, 2))).toBe(false);
    expect(isCrossed({ bid: 2, mark: 1.5, ask: 1 })).toBe(true);
    expect(isCrossed({ bid: 1, mark: 3, ask: 2 })).toBe(false);
  });
});

describe("row model — derived prices stay on the lit quote", () => {
  const flatQ = (q: Quote) => [q.bid, q.mark, q.ask];

  it("a flat Fwd quote with flat inputs gives flat outputs everywhere (bid = ask)", () => {
    let r = editRow(m, fresh(), "fwd", "bid", 6600);
    r = editRow(m, r, "fwd", "ask", 6600);
    expect(r.dark).toBe("fund");
    for (const c of ["synth", "trf", "fund", "div", "allIn"] as const) expect(r[c].bid).toBeCloseTo(r[c].ask, 8);
  });

  it("with a flat Fwd, Synth stays flat even when the dividends are two-way", () => {
    let r = editRow(m, fresh(), "fwd", "bid", 6600);
    r = editRow(m, r, "fwd", "ask", 6600);
    r = editRow(m, r, "div", "ask", r.div.bid * 1.1); // dark stays a non-price slot
    expect(r.dark).not.toBe("price");
    expect(r.synth.bid).toBeCloseTo(r.synth.ask, 8);
    expect(r.fwd.bid).toBe(6600);
  });

  it("a lit TRF two-way gives an uncrossed, consistent Fwd two-way", () => {
    const r = fresh();
    expect(isCrossed(r.fwd)).toBe(false);
    expect(isCrossed(r.synth)).toBe(false);
  });
});

describe("table — dividend scaling curve", () => {
  const table = () =>
    initialTable(m, SX5E_TRF_RUN.map((q) => ({ expiry: q.expiry, trf: twoWay(q.bid, q.offer) })), DEFAULT_ALL_IN);
  const [sep26, dec26, dec27, dec28] = [0.33, 30.25, 218.25, 409.25];

  it("Div shows cumulative gross dividends from valuation to expiry, scale 1 everywhere", () => {
    const t = table();
    expect(t[0].div.mark).toBeCloseTo(sep26 + dec26, 10);
    expect(t[1].div.mark).toBeCloseTo(sep26 + dec26 + dec27, 10);
    expect(t[2].div.mark).toBeCloseTo(sep26 + dec26 + dec27 + dec28, 10);
    expect(t.every((r) => r.divK.bid === 1 && r.divK.mark === 1 && r.divK.ask === 1)).toBe(true);
  });

  it("a Dec26 Div ask only moves Dec26: later rows see earlier windows at marks", () => {
    const t0 = table();
    const t = editTable(m, t0, 0, "div", "ask", (sep26 + dec26) * 1.1);
    expect(t[0].divK.ask).toBeCloseTo(1.1, 10);
    // higher divs lower the implied funding, so Div ask feeds the funding *bid*
    expect(t[0].fund.bid).toBeLessThan(t0[0].fund.bid);
    for (const i of [1, 2, 3, 4]) {
      expect(t[i].div).toEqual(t0[i].div);
      expect(t[i].fund).toEqual(t0[i].fund);
    }
  });

  it("a Dec27 Div bid scales only the Dec26 → Dec27 window", () => {
    const t = editTable(m, table(), 1, "div", "bid", 240);
    expect(t[1].divK.bid).toBeCloseTo((240 - sep26 - dec26) / dec27, 10);
    expect(t[0].div.bid).toBeCloseTo(sep26 + dec26, 10);
    expect(t[2].div.bid).toBeCloseTo(sep26 + dec26 + dec27 + dec28, 10); // Dec28 sees Dec27 at its mark
  });

  it("a solved Dec26 dividend two-way leaves Dec27 alone (it sees Dec26 at the mark scale)", () => {
    const t0 = table();
    let t = setSolveForTable(m, t0, 0, "div");
    t = editTable(m, t, 0, "fund", "bid", t[0].fund.bid - 2);
    expect(t[0].dark).toBe("div");
    expect(t[0].divK.bid).not.toBeCloseTo(1, 6); // Dec26's bid scale is implied...
    expect(t[0].divK.mark).toBe(1); // ...its mark is not
    expect(t[1].div).toEqual(t0[1].div);
  });
});

describe("table — start-up marks are held", () => {
  const quotes = SX5E_TRF_RUN.map((q) => ({ expiry: q.expiry, trf: twoWay(q.bid, q.offer) }));
  const cols = ["fwd", "synth", "trf", "fund", "divK", "allIn"] as const;

  it("a market move (spot) reprices bid/ask but no mark", () => {
    const t0 = initialTable(m, quotes, DEFAULT_ALL_IN);
    const t = solveTable({ ...m, spot: m.spot + 250 }, t0);
    for (let i = 0; i < t.length; i++) {
      for (const c of cols) expect(t[i][c].mark).toBe(t0[i][c].mark);
      expect(t[i].fwd.bid).not.toBeCloseTo(t0[i].fwd.bid, 2);
    }
  });

  it("rolling an expiry takes that expiry's start-up marks", () => {
    const t0 = initialTable(m, quotes, DEFAULT_ALL_IN);
    const marks = marksByExpiry(t0);
    const moved = solveTable({ ...m, spot: m.spot + 250 }, t0);
    const t = setExpiryTable({ ...m, spot: m.spot + 250 }, moved, 0, t0[1].expiry, marks);
    for (const c of cols) expect(t[0][c].mark).toBe(t0[1][c].mark);
  });
});

describe("table — solve for", () => {
  it("sets the solved slot for one row or for all rows", () => {
    const t0 = initialTable(m, SX5E_TRF_RUN.map((q) => ({ expiry: q.expiry, trf: twoWay(q.bid, q.offer) })), DEFAULT_ALL_IN);
    const one = setSolveForTable(m, t0, 2, "price");
    expect(one.map((r) => r.dark)).toEqual(["fund", "fund", "price", "fund", "fund"]);
    const all = setSolveForTable(m, t0, "all", "price");
    expect(all.every((r) => r.dark === "price")).toBe(true);
    // with every row on fair value at the solved funding, TRF reprices to the run
    all.forEach((r, i) => {
      expect(r.trf.bid).toBeCloseTo(SX5E_TRF_RUN[i].bid, 6);
      expect(r.trf.ask).toBeCloseTo(SX5E_TRF_RUN[i].offer, 6);
    });
  });
});

describe("table — forward funding", () => {
  const table = () =>
    initialTable(m, SX5E_TRF_RUN.map((q) => ({ expiry: q.expiry, trf: twoWay(q.bid, q.offer) })), DEFAULT_ALL_IN);
  const tau = (r: RowState) => {
    const s = P.settlementTimes(m.valuationDate, r.expiry.date);
    return s.expiry - s.spot;
  };

  it("first tenor: forward = term; later tenors chain off the previous term-funding mark", () => {
    const t = table();
    expect(t[0].fwdFund.mark).toBeCloseTo(t[0].fund.mark, 10);
    for (let i = 1; i < t.length; i++) {
      for (const s of ["bid", "mark", "ask"] as const) {
        // s_i·τ_i = s_{i−1,mark}·τ_{i−1} + φ_i·(τ_i − τ_{i−1})
        const lhs = t[i].fund[s] * tau(t[i]);
        const rhs = t[i - 1].fund.mark * tau(t[i - 1]) + t[i].fwdFund[s] * (tau(t[i]) - tau(t[i - 1]));
        expect(lhs).toBeCloseTo(rhs, 8);
      }
    }
  });

  it("solving for funding makes forward funding read-only too (same slot)", () => {
    const t = table();
    expect(t[1].dark).toBe("fund");
    expect(editTable(m, t, 1, "fwdFund", "ask", 999)).toEqual(solveTable(m, t));
  });

  it("lights one funding view at a time: term by default, forward once quoted", () => {
    const t = setSolveForTable(m, table(), "all", "price");
    expect(isLit(t[1], "fund")).toBe(true);
    expect(isLit(t[1], "fwdFund")).toBe(false);
    const e = editTable(m, t, 1, "fwdFund", "ask", t[1].fwdFund.ask + 1);
    expect(isLit(e[1], "fwdFund")).toBe(true);
    expect(isLit(e[1], "fund")).toBe(false);
    // both stay editable, and solving funding darkens both
    const solved = setSolveForTable(m, e, "all", "fund");
    expect(isLit(solved[1], "fund") || isLit(solved[1], "fwdFund")).toBe(false);
  });

  it("typing a forward funding sets the matching term funding, on that row only", () => {
    const t0 = setSolveForTable(m, table(), "all", "price");
    const t = editTable(m, t0, 2, "fwdFund", "ask", t0[2].fwdFund.ask + 10);
    expect(t[2].fwdFund.ask).toBeCloseTo(t0[2].fwdFund.ask + 10, 8);
    // term funding rises by 10bp × (τ − τ_prev)/τ
    const frac = (tau(t[2]) - tau(t[1])) / tau(t[2]);
    expect(t[2].fund.ask - t0[2].fund.ask).toBeCloseTo(10 * frac, 8);
    expect(t[2].trf.ask).toBeGreaterThan(t0[2].trf.ask);
    for (const i of [0, 1, 3, 4]) expect(t[i].fund).toEqual(t0[i].fund);
  });
});