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);
});
});