src/lib/carry/greeks.ts

105 lines
/**
 * Spot delta and IR DV01 of the TRF calendar spread.
 *
 * Each TRF leg is marked at its basis on top of the index, S · (1 + Y · Δ) index points per
 * contract (the accrual to date does not move), so for notional N = contracts · multiplier · S₀:
 *
 *   P&L_leg = sign · N / S₀ · [ S₁ · (1 + Y₁ · Δ) − S₀ · (1 + Y₀ · Δ) ]
 *
 * Holding the quoted spreads fixed, delta would just be the basis difference between the legs
 * and IR DV01 would be exactly 0 (€STR does not enter the TRF price). So the market is bumped
 * with the *assumptions* held — term funding, dividends, all-in — and the TRF spreads re-solved
 * (every row on "solve for prices"): that picks up the dividend pass-through moving with spot
 * (dividends are fixed in points) and the €STR carry of the hedge.
 */
import { editTable, setSolveForTable, type RowState } from "../grid/rowModel";
import type { MarketData } from "../market/sx5e";
import { settlementFraction } from "../pricing";

export interface LegGreeks {
  label: string;
  sign: number;
  /** P&L for +1% spot, EUR. */
  delta1pct: number;
  /** P&L for +1bp €STR, EUR. */
  irDV01: number;
}

export interface PositionGreeks {
  legs: [LegGreeks, LegGreeks];
  delta1pct: number;
  irDV01: number;
  /**
   * P&L for +1bp of Forward Funding between the two tenors (carry-model terms): the far tenor's
   * term funding is raised by (τ_far − τ_near)/τ_far bp with the near tenor and every other
   * assumption held, and the TRF spreads re-solved.
   */
  forwardFundingDV01: number;
}

const SPOT_BUMP = 0.01; // +1%
const RATE_BUMP = 1e-4; // +1bp

/** TRF mid spread (bp) per expiry label, for rows already priced from their assumptions. */
function midsOf(priced: RowState[]): Map<string, number> {
  const out = new Map<string, number>();
  for (const r of priced) if (!out.has(r.expiry.label)) out.set(r.expiry.label, (r.trf.bid + r.trf.ask) / 2);
  return out;
}

/** TRF mid spread (bp) per expiry label, with every row priced from its assumptions. */
const trfMids = (m: MarketData, rows: RowState[]) => midsOf(setSolveForTable(m, rows, "all", "price"));

export function positionGreeks(p: {
  market: MarketData;
  rows: RowState[];
  near: string;
  far: string;
  notional: number;
  longFar: boolean;
}): PositionGreeks {
  const { market: m, rows, notional: N } = p;
  const priced = setSolveForTable(m, rows, "all", "price");
  const base = midsOf(priced);
  const spotUp = { ...m, spot: m.spot * (1 + SPOT_BUMP) };
  const rateUp = { ...m, rate: m.rate + RATE_BUMP };
  const atSpot = trfMids(spotUp, rows);
  const atRate = trfMids(rateUp, rows);

  const leg = (label: string, sign: number): LegGreeks => {
    const row = rows.find((r) => r.expiry.label === label);
    if (!row) throw new Error("Unknown tenor");
    const delta = settlementFraction(m.valuationDate, row.expiry.date);
    const value = (s: number, y: number) => s * (1 + y * 1e-4 * delta);
    const y0 = base.get(label)!;
    const pnl = (s1: number, y1: number) => (sign * N * (value(s1, y1) - value(m.spot, y0))) / m.spot;
    return {
      label,
      sign,
      delta1pct: pnl(spotUp.spot, atSpot.get(label)!),
      irDV01: pnl(m.spot, atRate.get(label)!),
    };
  };

  const s = p.longFar ? 1 : -1;
  const legs: [LegGreeks, LegGreeks] = [leg(p.near, -s), leg(p.far, s)];

  // Forward Funding +1bp: far term funding += (τ_far − τ_near)/τ_far bp, near and the rest held
  const fi = priced.findIndex((r) => r.expiry.label === p.far);
  const ni = priced.findIndex((r) => r.expiry.label === p.near);
  const tau = (i: number) => settlementFraction(m.valuationDate, priced[i].expiry.date);
  const bump = (tau(fi) - tau(ni)) / tau(fi);
  let bumped = editTable(m, priced, fi, "fund", "bid", priced[fi].fund.bid + bump);
  bumped = editTable(m, bumped, fi, "fund", "ask", priced[fi].fund.ask + bump);
  const y1 = midsOf(bumped);
  const spreadPnl = (label: string, i: number, sign: number) =>
    sign * N * ((y1.get(label)! - base.get(label)!) * 1e-4) * tau(i);

  return {
    legs,
    delta1pct: legs[0].delta1pct + legs[1].delta1pct,
    irDV01: legs[0].irDV01 + legs[1].irDV01,
    forwardFundingDV01: spreadPnl(p.near, ni, -s) + spreadPnl(p.far, fi, s),
  };
}