/**
* Row model for the pricer grid. Each row is one equation price = f(funding, div, allIn),
* so exactly one "slot" is dark (solved) and the rest are lit (inputs).
*
* slots: price (Fwd | Synth | TRF share it) · fund · div · allIn
*
* Fwd, Synth and TRF are three quotes of the same thing — the forward — so one lit
* price determines the other two. The solved slot is an explicit choice per row
* (`setSolveFor`): "price" derives Fwd/Synth/TRF from the assumptions; an assumption
* slot is implied from whichever price was quoted last. Solved cells are read-only. A TRF
* quote can solve any assumption, but it only sees dividends through the (1 − allIn)
* pass-through, so implied dividends from a TRF are weakly determined.
*
* Every value is a quote: bid / mark / ask. Marks are a static reference layer: they are
* solved once, at start-up, from the row's `markSeed` (the initial set-up — e.g. the run's
* TRF mid, schedule dividends, 87% all-in, with funding solved) and then held. Neither
* bid/ask edits nor market data (spot, rates, date) move them; a row rolled to another
* expiry takes that expiry's start-up marks. Bid and ask are solved around the marks:
* each side of an output takes the input sides that push it that way, picked by the sign
* of the sensitivity at the marks — e.g. a synthetic is decreasing in dividends, so
* Synth bid uses the Div *ask*.
*
* Dividends follow a scaling curve with the same windows as the tenors: a row's `divK`
* scales the dividends going ex after the previous tenor's expiry, up to its own. The Div
* column shows the resulting cumulative gross dividends from valuation to expiry. Each
* row's bid/ask depend only on its own inputs and marks: earlier windows always enter at
* their mark scale. Marks still flow down the curve, so solve with `solveTable`.
*/
import type { MarketData } from "../market/sx5e";
import {
fairSyntheticPrice,
fairTRFSpread,
implied,
priceForward,
settlementTimes,
trfSchedule,
yearFraction,
type Dividend,
type Expiry,
type ForwardArgs,
type Pricer,
type SolvableKey,
type TRFSchedule,
} from "../pricing";
export type Col = "fwd" | "synth" | "trf" | "fund" | "fwdFund" | "div" | "allIn";
export type Slot = "price" | "fund" | "div" | "allIn";
export type PriceKind = "fwd" | "synth" | "trf";
export type FundKind = "fund" | "fwdFund";
export type Side = "bid" | "mark" | "ask";
export interface Quote {
bid: number;
mark: number;
ask: number;
}
export interface RowState {
expiry: Expiry;
/** Index forward to expiry, index points. */
fwd: Quote;
/** Synthetic quoted as the switch vs spot (synthetic − spot), index points. */
synth: Quote;
/** TRF spread, bp. */
trf: Quote;
/** Term funding spread (hedge funding to expiry), bp. */
fund: Quote;
/**
* Forward funding from the previous tenor's expiry to this one, bp (derived from term
* funding and the previous tenor's term-funding mark): s·τ = s_prev·τ_prev + φ·(τ − τ_prev).
*/
fwdFund: Quote;
/** Cumulative gross dividends from valuation to expiry, index points (derived from the curve). */
div: Quote;
/** Scaling curve value for this tenor's dividend window, per side (1 = schedule). */
divK: Quote;
/** All-in payout factor (0.87 = 87%). */
allIn: Quote;
priceKind: PriceKind;
/**
* Which funding view is the input when funding isn't solved: the one quoted last (term by
* default). Term and Forward Funding are one input, so only one of them is lit.
*/
fundKind?: FundKind;
/** The solved slot (chosen explicitly). */
dark: Slot;
/** Fixed inputs of the mark layer (set once from the initial values). */
markSeed: MarkSeed;
/** Marks have been solved (at start-up) and are held from then on. */
marked?: boolean;
error?: string;
}
/**
* The mark layer's own equation: `priceKind` quoted at `price`, the other inputs as given,
* and `dark` solved. Bid/ask edits never touch it.
*/
export interface MarkSeed {
priceKind: PriceKind;
price: number;
dark: Slot;
/** Funding spread, bp (ignored when funding is the solved slot). */
fund: number;
divK: number;
allIn: number;
}
export const SIDES: Side[] = ["bid", "mark", "ask"];
/** The sides solved around the marks. */
const TWO_WAY = ["bid", "ask"] as const;
const opp = (s: Side): Side => (s === "bid" ? "ask" : s === "ask" ? "bid" : "mark");
export const flat = (x: number): Quote => ({ bid: x, mark: x, ask: x });
export const twoWay = (bid: number, ask: number): Quote => ({ bid, mark: (bid + ask) / 2, ask });
/** Bid above ask. (A mark outside bid/ask is fine: it is a fixed reference, not a mid.) */
export const isCrossed = (q: Quote, eps = 1e-9) => q.bid > q.ask + eps;
const perSide = <T>(f: (s: Side) => T): Record<Side, T> => ({ bid: f("bid"), mark: f("mark"), ask: f("ask") });
type Input = "fund" | "div" | "allIn";
const INPUTS: Input[] = ["fund", "div", "allIn"];
const PRICES: PriceKind[] = ["fwd", "synth", "trf"];
const KEY: Record<Input, SolvableKey> = { fund: "fundingSpread", div: "divScale", allIn: "allIn" };
const PRICER: Record<PriceKind, Pricer> = { fwd: priceForward, synth: fairSyntheticPrice, trf: fairTRFSpread };
export const slotOf = (c: Col): Slot =>
c === "fwd" || c === "synth" || c === "trf" ? "price" : c === "fwdFund" ? "fund" : c;
export function isLit(row: RowState, c: Col): boolean {
const s = slotOf(c);
if (s === "price") return row.dark !== "price" && row.priceKind === c;
if (s === "fund") return row.dark !== "fund" && (row.fundKind ?? "fund") === c;
return row.dark !== s;
}
/* ---------------- dividend scaling curve ---------------- */
/** The earlier tenors' part of the curve, as seen by a row. */
export interface PriorDivs {
/** The previous tenor's expiry, years from valuation: this row's window starts after it. */
from: number;
/** Dividends up to `from`, scaled by the earlier tenors' mark curve (same on every side). */
scaled: Record<Side, Dividend[]>;
/** Their cumulative gross amount, per side. */
cum: Record<Side, number>;
/** The previous tenor's term-funding mark (bp) and its carry period τ (years): forward funding starts there. */
fundMark: number;
fundTau: number;
}
export const noPrior = (): PriorDivs => ({
from: 0,
scaled: perSide(() => []),
cum: perSide(() => 0),
fundMark: 0,
fundTau: 0,
});
/** Carry period of the hedge funding: spot settlement → expiry settlement, years. */
const fundTau = (a: ForwardArgs) => (a.settle?.expiry ?? a.t) - (a.settle?.spot ?? 0);
/** Forward funding (bp) from the previous tenor to this one, for a term funding s (bp). */
function toForwardFunding(prior: PriorDivs, tau: number, s: number): number {
const span = tau - prior.fundTau;
return span > 1e-12 ? (s * tau - prior.fundMark * prior.fundTau) / span : s;
}
/** Term funding (bp) implied by a forward funding φ (bp) after the previous tenor. */
function fromForwardFunding(prior: PriorDivs, tau: number, phi: number): number {
const span = tau - prior.fundTau;
return span > 1e-12 ? (prior.fundMark * prior.fundTau + phi * span) / tau : phi;
}
interface Ctx {
base: ForwardArgs;
prior: PriorDivs;
/** Schedule dividends in this row's window (from, t]. */
own: Dividend[];
ownGross: number;
/** Full dividend list per side: earlier windows scaled, own window unscaled (`divScale` = divK). */
divs: Record<Side, Dividend[]>;
}
const schedules = new Map<string, TRFSchedule>();
/** Settlement schedule per (valuation, expiry): ~1,100 steps, so build it once. */
function scheduleFor(valuationDate: Date, expiry: Date): TRFSchedule {
const key = `${valuationDate.getTime()}:${expiry.getTime()}`;
let s = schedules.get(key);
if (!s) schedules.set(key, (s = trfSchedule(valuationDate, expiry)));
return s;
}
function context(m: MarketData, expiry: Expiry, prior: PriorDivs = noPrior()): Ctx {
const t = yearFraction(m.valuationDate, expiry.date);
const all = m.dividends.map((d) => ({ t: yearFraction(m.valuationDate, d.date), gross: d.gross }));
const own = all.filter((d) => d.t > Math.max(0, prior.from) && d.t <= t);
const base: ForwardArgs = {
spot: m.spot,
t,
rate: m.rate,
fundingSpread: 0,
dividends: own,
allIn: 1,
divScale: 1,
divScaleFrom: prior.from,
eqlSpread: m.eqlSpread,
trf: scheduleFor(m.valuationDate, expiry.date),
settle: settlementTimes(m.valuationDate, expiry.date),
};
return {
base,
prior,
own,
ownGross: own.reduce((acc, d) => acc + d.gross, 0),
divs: perSide((s) => [...prior.scaled[s], ...own]),
};
}
/** Cumulative gross dividends to expiry for a window scale k on a given side. */
const cumDiv = (ctx: Ctx, side: Side, k: number) => ctx.prior.cum[side] + k * ctx.ownGross;
/** Args for one evaluation: each input on its chosen side; `x` (if given) set to a model value. */
function argsAt(ctx: Ctx, row: RowState, side: (i: Input) => Side): ForwardArgs {
return {
...ctx.base,
dividends: ctx.divs[side("div")],
fundingSpread: row.fund[side("fund")] / 1e4,
divScale: row.divK[side("div")],
allIn: row.allIn[side("allIn")],
};
}
/** ∂pricer/∂key by central difference (only the sign is used). */
function slope(p: Pricer, a: ForwardArgs, key: SolvableKey): number {
const h = 1e-6 * Math.max(1, Math.abs(a[key] as number));
return (p({ ...a, [key]: (a[key] as number) + h }) - p({ ...a, [key]: (a[key] as number) - h })) / (2 * h);
}
/**
* The mark layer: solves the row's `markSeed` on its own (marks never follow bid/ask
* edits) and writes every column's mark. Returns the args at the marks.
*/
function solveMarks(ctx: Ctx, row: RowState): ForwardArgs {
const seed = row.markSeed;
const a: ForwardArgs = {
...ctx.base,
dividends: ctx.divs.mark,
fundingSpread: seed.fund / 1e4,
divScale: seed.divK,
allIn: seed.allIn,
};
if (seed.dark !== "price") {
const x = seed.dark as Input;
a[KEY[x]] = implied(PRICER[seed.priceKind], KEY[x], seed.price, a);
}
row.fund = { ...row.fund, mark: a.fundingSpread * 1e4 };
row.divK = { ...row.divK, mark: a.divScale };
row.allIn = { ...row.allIn, mark: a.allIn };
for (const k of PRICES) row[k] = { ...row[k], mark: PRICER[k](a) };
row.marked = true;
return a;
}
/**
* Solves the dark slot's bid/ask (generic Brent `implied`) and the derived prices' bid/ask,
* around the marks. Marks are solved only for a row that has none yet (start-up).
*/
export function solveRow(m: MarketData, input: RowState, prior: PriorDivs = noPrior()): RowState {
const row: RowState = { ...input, error: undefined };
const ctx = context(m, row.expiry, prior);
try {
// held marks still give the sensitivities their base point (at today's market)
const markArgs = row.marked ? argsAt(ctx, row, () => "mark") : solveMarks(ctx, row);
if (row.dark !== "price") {
const x = row.dark;
const p = PRICER[row.priceKind];
const dPdX = slope(p, markArgs, KEY[x]);
const quote = row[row.priceKind];
const solved = { bid: 0, ask: 0 };
for (const side of TWO_WAY) {
// x = h(quote, others): ∂h/∂quote ∝ 1/∂P/∂x, ∂h/∂y ∝ −(∂P/∂y)/(∂P/∂x)
const quoteSide = dPdX >= 0 ? side : opp(side);
const a = argsAt(ctx, row, (i) =>
i === x ? side : -slope(p, markArgs, KEY[i]) * dPdX >= 0 ? side : opp(side),
);
solved[side] = implied(p, KEY[x], quote[quoteSide], a);
}
if (x === "div") row.divK = { ...row.divK, ...solved };
else if (x === "fund") row.fund = { ...row.fund, bid: solved.bid * 1e4, ask: solved.ask * 1e4 };
else row.allIn = { ...row.allIn, ...solved };
// Derived prices must stay on the constraint P(args) = quote: x is a function of the
// quote and the other inputs, so use *total* sensitivities (through x) to pick sides,
// then re-solve x in each side's scenario. E.g. Synth with a lit Fwd has total
// sensitivity 0 to everything, so it comes out flat when the Fwd quote is flat.
for (const kind of PRICES) {
if (kind === row.priceKind) continue;
const q = PRICER[kind];
const dQdX = slope(q, markArgs, KEY[x]);
const dQdQuote = dQdX / dPdX;
const total = (i: Input) => slope(q, markArgs, KEY[i]) - (dQdX * slope(p, markArgs, KEY[i])) / dPdX;
const at = (side: "bid" | "ask") => {
const a = argsAt(ctx, row, (i) => (i === x ? side : total(i) >= 0 ? side : opp(side)));
const xs = implied(p, KEY[x], quote[dQdQuote >= 0 ? side : opp(side)], a);
return q({ ...a, [KEY[x]]: xs });
};
row[kind] = { ...row[kind], bid: at("bid"), ask: at("ask") };
}
} else {
// fair value: no constraint, every input picks the side that pushes the price that way
for (const kind of PRICES) {
const p = PRICER[kind];
const at = (side: "bid" | "ask") =>
p(argsAt(ctx, row, (i) => (slope(p, markArgs, KEY[i]) >= 0 ? side : opp(side))));
row[kind] = { ...row[kind], bid: at("bid"), ask: at("ask") };
}
}
} catch (e) {
row.error = e instanceof Error ? e.message : String(e);
}
row.div = perSide((s) => cumDiv(ctx, s, row.divK[s]));
row.fwdFund = perSide((s) => toForwardFunding(prior, fundTau(ctx.base), row.fund[s]));
return row;
}
/**
* The curve a row hands on to the next tenor: its window at the *mark* scale, on every side.
* A row's bid/ask only ever moves its own window — later rows see earlier windows at marks.
*/
function extendPrior(m: MarketData, row: RowState, prior: PriorDivs): PriorDivs {
const ctx = context(m, row.expiry, prior);
const k = row.divK.mark;
return {
from: ctx.base.t,
scaled: perSide((s) => [...prior.scaled[s], ...ctx.own.map((d) => ({ ...d, gross: d.gross * k }))]),
cum: perSide((s) => cumDiv(ctx, s, k)),
fundMark: row.fund.mark,
fundTau: fundTau(ctx.base),
};
}
/** Whether a cell can be typed into: marks never, solved cells never. */
export const isEditable = (row: RowState, col: Col, side: Side) => side !== "mark" && slotOf(col) !== row.dark;
/**
* Edits a bid or ask of an input; marks are derived and solved cells are read-only (ignored).
* Quoting a price makes it the lit price. A Div edit sets this tenor's window scale.
*/
export function editRow(
m: MarketData,
row: RowState,
col: Col,
side: Exclude<Side, "mark">,
value: number,
prior: PriorDivs = noPrior(),
): RowState {
const s = slotOf(col);
if (s === row.dark) return row;
const next: RowState = { ...row };
if (col === "div") {
const ctx = context(m, row.expiry, prior);
const k = ctx.ownGross > 0 ? (value - ctx.prior.cum[side]) / ctx.ownGross : row.divK[side];
next.divK = { ...row.divK, [side]: k };
} else if (col === "fwdFund") {
// a forward funding quote sets the matching term funding on that side
const ctx = context(m, row.expiry, prior);
next.fund = { ...row.fund, [side]: fromForwardFunding(prior, fundTau(ctx.base), value) };
} else {
next[col] = { ...row[col], [side]: value };
}
if (s === "price") next.priceKind = col as PriceKind;
if (s === "fund") next.fundKind = col as FundKind;
return solveRow(m, next, prior);
}
/**
* Chooses the solved slot. The previously solved values stay as they are and become inputs;
* when an assumption is solved, the lit price is the one quoted last (`priceKind`).
*/
export function setSolveFor(m: MarketData, row: RowState, dark: Slot, prior: PriorDivs = noPrior()): RowState {
return row.dark === dark ? row : solveRow(m, { ...row, dark }, prior);
}
/** Rolls the expiry; lit inputs and the window scale stay. */
export function setExpiry(m: MarketData, row: RowState, expiry: Expiry, prior: PriorDivs = noPrior()): RowState {
return solveRow(m, { ...row, expiry }, prior);
}
/**
* Market-run row: TRF quote, schedule dividends (scale 1) and all-in lit; funding solved.
* The same set-up at the TRF's mid is frozen as the mark seed.
*/
export function initialRow(
m: MarketData,
expiry: Expiry,
trf: Quote,
allIn: number,
prior: PriorDivs = noPrior(),
): RowState {
return solveRow(
m,
{
expiry,
fwd: flat(0),
synth: flat(0),
trf,
fund: flat(0),
fwdFund: flat(0),
div: flat(0),
divK: flat(1),
allIn: flat(allIn),
priceKind: "trf",
dark: "fund",
markSeed: { priceKind: "trf", price: (trf.bid + trf.ask) / 2, dark: "fund", fund: 0, divK: 1, allIn },
},
prior,
);
}
/* ---------------- table: rows linked by the dividend curve ---------------- */
/**
* Walks the rows in expiry order, giving each the curve of the tenors before it.
* `step` decides what happens to each row (re-solve, or keep as is). Rows sharing an
* expiry see the same prior; the first of them extends the curve.
*/
function walk(m: MarketData, rows: RowState[], step: (row: RowState, prior: PriorDivs, i: number) => RowState) {
const order = rows.map((_, i) => i).sort((a, b) => rows[a].expiry.date.getTime() - rows[b].expiry.date.getTime());
const out = [...rows];
const priors: PriorDivs[] = new Array(rows.length);
let prior = noPrior();
let lastExpiry = -Infinity;
let pending: PriorDivs | null = null;
for (const i of order) {
const time = rows[i].expiry.date.getTime();
if (time > lastExpiry && pending) {
prior = pending;
pending = null;
}
priors[i] = prior;
out[i] = step(rows[i], prior, i);
if (time > lastExpiry) {
pending = extendPrior(m, out[i], prior);
lastExpiry = time;
}
}
return { rows: out, priors };
}
/** Re-solves every row's bid/ask in expiry order (marks are held). */
export const solveTable = (m: MarketData, rows: RowState[]) => walk(m, rows, (r, p) => solveRow(m, r, p)).rows;
/* ---------------- start-up marks ---------------- */
const MARK_COLS = ["fwd", "synth", "trf", "fund", "divK", "allIn"] as const;
export type Marks = Record<(typeof MARK_COLS)[number], number>;
/** The start-up marks, by expiry label: the snapshot every row's marks come from. */
export function marksByExpiry(rows: RowState[]): Record<string, Marks> {
const out: Record<string, Marks> = {};
for (const r of rows) {
if (out[r.expiry.label]) continue;
out[r.expiry.label] = Object.fromEntries(MARK_COLS.map((c) => [c, r[c].mark])) as Marks;
}
return out;
}
function withMarks(row: RowState, marks: Marks): RowState {
const next = { ...row, marked: true };
for (const c of MARK_COLS) next[c] = { ...row[c], mark: marks[c] };
return next;
}
export function editTable(
m: MarketData,
rows: RowState[],
index: number,
col: Col,
side: Exclude<Side, "mark">,
value: number,
): RowState[] {
const { priors } = walk(m, rows, (r) => r);
const next = [...rows];
next[index] = editRow(m, rows[index], col, side, value, priors[index]);
return solveTable(m, next);
}
/** Sets the solved slot for one row (index) or for every row ("all"). */
export function setSolveForTable(m: MarketData, rows: RowState[], target: number | "all", dark: Slot): RowState[] {
const next = rows.map((r, i) => (target === "all" || i === target ? { ...r, dark } : r));
return solveTable(m, next);
}
/** Rolls a row to another expiry, taking that expiry's start-up marks. */
export function setExpiryTable(
m: MarketData,
rows: RowState[],
index: number,
expiry: Expiry,
initialMarks: Record<string, Marks>,
): RowState[] {
const next = [...rows];
const marks = initialMarks[expiry.label];
// an expiry outside the start-up set has no snapshot: mark it once, then hold
next[index] = marks ? withMarks({ ...rows[index], expiry }, marks) : { ...rows[index], expiry, marked: false };
return solveTable(m, next);
}
export function initialTable(m: MarketData, quotes: { expiry: Expiry; trf: Quote }[], allIn: number): RowState[] {
// mark every row once, with the dividend curve of the tenors before it
const rows = quotes.map((q) => ({ ...initialRow(m, q.expiry, q.trf, allIn), marked: false }));
return solveTable(m, rows);
}