src/lib/pricing/time.ts

102 lines
/** Year basis for all `t` values in the engine (ACT/360): t = 1 means 360 calendar days. */
export const DAYS_PER_YEAR = 360;

const MS_PER_DAY = 86_400_000;

export const utc = (year: number, monthIndex: number, day: number) =>
  new Date(Date.UTC(year, monthIndex, day));

export function yearFraction(from: Date, to: Date): number {
  return (to.getTime() - from.getTime()) / MS_PER_DAY / DAYS_PER_YEAR;
}

/** Third Friday of the month: standard Eurex expiry for SX5E futures and TRFs. */
export function thirdFriday(year: number, monthIndex: number): Date {
  const first = utc(year, monthIndex, 1).getUTCDay();
  const firstFriday = 1 + ((5 - first + 7) % 7);
  return utc(year, monthIndex, firstFriday + 14);
}

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

export interface Expiry {
  label: string; // e.g. "Dec26"
  date: Date;
}

/** Parses "Dec26" → { label, date: third Friday of Dec 2026 }. */
export function expiryFromLabel(label: string): Expiry {
  const m = /^([A-Za-z]{3})(\d{2})$/.exec(label.trim());
  if (!m) throw new Error(`Bad expiry label: ${label}`);
  const monthIndex = MONTHS.findIndex((x) => x.toLowerCase() === m[1].toLowerCase());
  if (monthIndex < 0) throw new Error(`Bad month in expiry label: ${label}`);
  const year = 2000 + Number(m[2]);
  return { label: `${MONTHS[monthIndex]}${m[2]}`, date: thirdFriday(year, monthIndex) };
}

export const isoDate = (d: Date) => d.toISOString().slice(0, 10);

/* ---------- settlement (weekends only, no holiday calendar) ---------- */

/** Standard settlement period of the index, business days. */
export const SETTLEMENT_DAYS = 2;
/** Day count basis of the TRF. */
export const TRF_DAY_COUNT_BASIS = 360;

const isWeekend = (d: Date) => d.getUTCDay() === 0 || d.getUTCDay() === 6;
const addDays = (d: Date, n: number) => new Date(d.getTime() + n * MS_PER_DAY);

/** The day n weekdays after d (weekends skipped; holidays ignored). */
export function addWeekdays(d: Date, n: number): Date {
  let out = d;
  for (let k = 0; k < n; ) {
    out = addDays(out, 1);
    if (!isWeekend(out)) k++;
  }
  return out;
}

/** Calendar days between the settlement dates of t0 and t1: day(t1, n) − day(t0, n). */
export function settlementDays(t0: Date, t1: Date, n = SETTLEMENT_DAYS): number {
  return Math.round((addWeekdays(t1, n).getTime() - addWeekdays(t0, n).getTime()) / MS_PER_DAY);
}

/** Δ_SSP(t0, t1): settlement-adjusted day count fraction, ACT/360. */
export const settlementFraction = (t0: Date, t1: Date, n = SETTLEMENT_DAYS) =>
  settlementDays(t0, t1, n) / TRF_DAY_COUNT_BASIS;

/**
 * Date-free schedule the TRF pricer needs (Eurex Circular 086/21, Att. 3):
 *  - basisDelta = Δ_SSP(t, T)
 *  - one funding step per business day τ ∈ (t, T]: the forward is read at τ−1
 *    (u, years from valuation) and accrues over Δ_SSP(τ−1, τ)
 */
export interface TRFSchedule {
  basisDelta: number;
  /** u: τ−1 in years from valuation; settle: its settlement date (τ−1 + 2 weekdays), same units. */
  steps: { u: number; settle: number; delta: number }[];
}

export function trfSchedule(valuationDate: Date, expiry: Date): TRFSchedule {
  const steps: TRFSchedule["steps"] = [];
  let prev = valuationDate;
  for (let d = addDays(valuationDate, 1); d.getTime() <= expiry.getTime(); d = addDays(d, 1)) {
    if (isWeekend(d)) continue;
    steps.push({
      u: yearFraction(valuationDate, prev),
      settle: yearFraction(valuationDate, addWeekdays(prev, SETTLEMENT_DAYS)),
      delta: settlementFraction(prev, d),
    });
    prev = d;
  }
  return { basisDelta: settlementFraction(valuationDate, expiry), steps };
}

/** Spot and expiry settlement dates in years from valuation (for `ForwardArgs.settle`). */
export function settlementTimes(valuationDate: Date, expiry: Date) {
  return {
    spot: yearFraction(valuationDate, addWeekdays(valuationDate, SETTLEMENT_DAYS)),
    expiry: yearFraction(valuationDate, addWeekdays(expiry, SETTLEMENT_DAYS)),
  };
}