kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
124 lines (123 loc) • 7.81 kB
JavaScript
/**
* # src/fill/mark-to-model — the PER-WAKE mark-to-model of held option legs (kestrel theta-cell seam b)
*
* ## The gap this closes
* The sim marks an option position ONCE: {@link import("./engine.ts").SimFillEngine} cash-settles filled
* inventory at **intrinsic at the close**. So a held straddle's loss lands only at the terminal settle — there is NO
* intra-session mark between wakes, and therefore no dense signal that punishes STANDING DOWN on a
* position that is bleeding theta. On the FOMC-OPTIONS WHIPSAW day that terminal floor is already −$186,
* but a watcher graded per-wake sees a FLAT position at every interim wake and pays no running cost for
* doing nothing.
*
* This module adds that missing intra-session mark: at each wake it re-marks the held legs against the
* **tape's own option book** (the bid-side / sellable value folded as-of that wake ts) and reports the
* running mark-to-market versus the premium basis. A held long straddle whose premium is decaying shows
* a progressively NEGATIVE mark between wakes — so standing down becomes natively costly, which is the
* whole point of the theta cell (it structurally dissolves the "doing-nothing-wins" degenerate reward).
*
* ## Gated, additive, byte-identical when OFF
* The entire pass is behind {@link MarkToModelInput.markToModel}. When it is `false` (the default for
* every existing cell — their pinned floors do not move) the function returns an EMPTY mark series and a
* `standDownFloorUsd` of exactly `0`: nothing is marked, nothing downstream changes. Only a cell that
* opts in (`markToModel: true` on its frame) gets the per-wake bleed.
*
* ## What it is NOT (the clean boundary)
* This is a MARKING seam, not a settlement rewrite. It does not touch the τ provider
* ({@link import("../session/clock.ts").expiryTauYears}), does not touch `SimFillEngine.settle`, and does
* not compute position greeks (delta/gamma/theta as first-class quantities — that is the watcher-pareto
* peer's percept pane). It marks against the observed book, the one honest closeable value; a Black-76
* `@fair` mark is a deliberate alternative NOT taken here.
*
* ## Why kestrel-wcnd (τ-from-expiry) does not touch this file's MATH
* The τ bug mispriced every model-derived value. This seam derives NO model value: `sellablePerContract`
* reads the tape's own posted bid/ask, which needs no τ, no forward, and no vol surface. So the marking
* is unaffected by the τ fix BY CONSTRUCTION — it is exactly the property that made an observed-book mark
* the honest choice here. What DOES change is the leg-tenor assertion below: it now guards a `book.expiry`
* that genuinely drives pricing elsewhere, so a multi-tenor position (the ONE case where a single
* `book.expiry` cannot serve every leg) must keep surfacing loudly rather than mark a leg against another
* contract's book.
*/
import { foldBook } from "../bus/types.js";
/** The standard listed-option contract multiplier (premiums + P&L are per-contract-×-100 dollars),
* matching `src/session/sim.ts` execSpec for an option underlier. */
export const OPTION_CONTRACT_MULTIPLIER = 100;
const r2 = (x) => Math.round(x * 100) / 100;
/** Fold the running option book of `instrument` in ONE forward pass, snapshotting the book state at
* each wake ts (each snapshot folds only events at-or-before its ts — no lookahead). Pure. O(events +
* wakes). Returns the book state to mark against at each ascending wake (`null` before the first BOOK). */
function booksAtWakes(events, instrument, ascWakes) {
const out = new Array(ascWakes.length).fill(null);
let state;
let w = 0;
for (const ev of events) {
// Every wake strictly before this event's ts sees the book as folded so far (no lookahead).
while (w < ascWakes.length && ascWakes[w] < ev.ts) {
out[w] = state ?? null;
w++;
}
if (w >= ascWakes.length)
break; // all wakes resolved
if (ev.stream === "TICK" && ev.type === "BOOK" && ev.instrument === instrument) {
state = foldBook(ev, state);
}
}
for (; w < ascWakes.length; w++)
out[w] = state ?? null; // wakes at/after the last event see the final book
return out;
}
/** The per-contract sellable (bid-side) value of one leg against a book state. A long leg is closed by
* SELLING at the bid; a short leg is closed by BUYING at the ask. A dark quote counts 0 (genuinely
* uncloseable), matching `measureOptionTape`'s bid-side convention. */
function sellablePerContract(leg, book) {
if (book === null)
return 0;
const q = book.legs.find((l) => l.strike === leg.strike && l.right === leg.right);
if (q === undefined)
return 0;
// Long ⇒ we receive the bid on close; short ⇒ we pay the ask to close (a cost, so negative sellable).
return leg.side === "buy" ? q.bid ?? 0 : -(q.ask ?? 0);
}
/**
* Mark the held legs at each wake against the tape's option book. See the module header. Pure; no clock,
* no RNG, no lookahead (each wake folds the book only up to its own ts).
*/
export function markHeldLegsPerWake(input) {
const mult = input.multiplier ?? OPTION_CONTRACT_MULTIPLIER;
if (input.markToModel !== true) {
return { enabled: false, marks: [], standDownFloorUsd: 0 };
}
const marks = [];
const ascWakes = [...input.wakeTsList].sort((a, b) => a - b);
const books = booksAtWakes(input.events, input.instrument, ascWakes);
for (let i = 0; i < ascWakes.length; i++) {
const ts = ascWakes[i];
const book = books[i];
// Seam a is load-bearing: if a leg carries its own tenor, it must match the single-expiry book it is
// marked against — a mismatch is the (unbuilt) multi-expiry re-keying boundary, surfaced loudly.
for (const leg of input.legs) {
if (leg.expiry?.kind === "expiry-tag" && book?.expiry !== undefined && leg.expiry.tag !== book.expiry) {
throw new Error(`mark-to-model: held leg ${leg.strike}${leg.right} tenor "${leg.expiry.tag}" != book expiry "${book.expiry}" — ` +
`the execution book is keyed by symbol with ONE expiry; a multi-tenor position needs the (symbol, expiry) ` +
`re-keying that is deliberately out of scope for this marking seam.`);
}
}
let sellable = 0;
let mtm = 0;
for (const leg of input.legs) {
const s = sellablePerContract(leg, book);
sellable += s;
// Mark-to-market vs the premium BASIS, signed by side. `sellablePerContract` already carries the
// close side: a LONG's `s` is +bid (received on close), a SHORT's `s` is −ask (paid to close). The
// basis, however, is stored UNSIGNED per leg (`reconstructHeldOptionLegs` gives a short a POSITIVE
// credit). A long paid its basis (a debit) ⇒ P&L = s − basis; a short RECEIVED its basis (a credit)
// ⇒ P&L = s + basis = credit − ask. Using `s − basis` for a short computed −ask − credit (a
// fabricated loss of 2×credit×qty×mult) instead of credit − ask — the sign bug this expression fixes
// (kestrel-wt8c; folded into the bankable `totals.floor`).
mtm += (leg.side === "buy" ? s - leg.basisPremium : s + leg.basisPremium) * leg.qty * mult;
}
marks.push({ ts, bookAsOfTs: book?.asof_ts ?? null, sellablePerContract: r2(sellable), mtmUsd: r2(mtm) });
}
// The stand-down floor is the mark at the LAST wake (the terminal intra-session value of holding).
const standDownFloorUsd = marks.length > 0 ? marks[marks.length - 1].mtmUsd : 0;
return { enabled: true, marks, standDownFloorUsd };
}