src/lib/grid/rowModel.ts

512 lines
/**
 * 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);
}