UNPKG

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.

151 lines (126 loc) 9.38 kB
# OSS-ADR-0050 — A leg carries its own expiry; `exp` marks it and the tenor inherits Status: accepted (2026-07-16, owner ratified the `exp` coinage) > **Implementation status (2026-07-18):** Accepted; **decisions 1–7 LANDED** in `32e67a5` > (*per-leg expiry authoring + widen `#execFor` to a (symbol, expiry) key*, kestrel-ih5h > seam 1), with the end-to-end fixture in `714118c`. **Decision 8 (re-keying the book) > remains DEFERRED** — see below. Supersedes the 2026-07-17 audit note, which read > "not yet implemented" and is now false in every particular. > > Landed in `32e67a5`: > > - **`exp` leg-expiry marker** — `parseLeg` (`src/lang/parse.ts`) reads `exp` via > `c.optWord("exp")` and reuses `parseExpiry`/`ExpirySelector` verbatim (decisions 1–2). > - **Bare `exp` fails closed** — a following punct token, or end of input, is a parse > escape naming every legal form, never a silent inherit (decision 4). > - **Builder parameter** — `buy(qty, strike, right, expiry?)` / `sell(...)` in > `src/lang/builders.ts`, the object peer of the text tail (decision 6, OSS-ADR-0004). > Purely additive: the `exp` tail prints only when `expiry !== undefined`, so > pre-existing plans print byte-identical bytes and no golden fixture changed. > - **`#execFor` widened** — it now returns the `ExecRef` `(symbol, expiry)` pair rather > than a bare string, so `using.exec.expiry` is no longer dropped (decision 7). > `#execForLeg` lets a leg's own `exp` override the ambient tenor, and `#bookFor` — the > single place a book is fetched — refuses a book whose tenor CONTRADICTS the authored > one. A refused book quotes dark, so the line is unresolvable and the plan stays armed > and retries: fail-closed, never a fill against the wrong tenor. > > Fixture (`714118c`): `tests/bench.theta-cell-per-leg-expiry-e2e.test.ts` — a QQQ 0DTE > ATM straddle on a WHIPSAW tape whose ambient `USING exec QQQ 2026-08-21` names a tenor > the book does not carry, every leg authoring `exp 2026-07-17`, driven through the real > `runSimulateSession`. > > **Still deferred — decision 8 only.** `foldBook` / `BookState` (`src/bus/types.ts`) and > the sim's `#books` are unchanged: `src/session/sim.ts` still holds > `#books = new Map<string, BookState>()` and folds with > `this.#books.set(ev.instrument, foldBook(ev, this.#books.get(ev.instrument)))` — one > expiry per instrument, keyed by symbol alone. `BookState.expiry` is consumed read-only. > The cross-expiry contamination named in the Context therefore **remains latent**, and > `mark-to-model.ts`'s expiry assertion remains the loud tripwire. Sequenced after > `kestrel-wcnd` for the reason decision 8 gives. ## Context `OptionLeg.expiry?: ExpirySelector` already exists in `src/lang/ast.ts` — landed gated-off — but **nothing could author it**. `ast.ts` says so outright: the plan grammar has no per-leg expiry syntax, so *every parsed or built leg leaves this `undefined`*. The field was a seam with no surface. The selector *language* already exists too. `parseExpiry` (`src/lang/parse.ts`) reads the three `ExpirySelector` forms — relative (`0dte`), a date (`2026-07-17`), a tag (`weekly`) — but only via `parseInstrument`, i.e. only for `USING exec SPY 0dte`. So Kestrel could name an expiry for an *instrument* and not for a *leg*. Two resolution sites are consequently expiry-blind: - `#execFor` (`src/engine/plans.ts`) returns a bare `string` — `pr.plan.using?.exec?.symbol ?? this.#execDefault ?? ""` — and **drops `using.exec.expiry` entirely**. A plan whose exec tenor contradicts the book's tenor fills anyway, against a book it should never have seen. - `foldBook` / `BookState` (`src/bus/types.ts`) carry **one expiry per instrument** as a latest-wins scalar, while legs merge on a `strike:right` key with **no expiry in the key**; `src/session/sim.ts` keys `#books` by instrument symbol alone. A second-expiry BOOK event therefore overwrites the scalar while merging its legs into the same map — silent cross-expiry contamination. `mark-to-model.ts` currently *asserts* held-leg expiry matches the book's single expiry as a deliberate tripwire. `CONTEXT.md` carried **zero** expiry vocabulary (`grep -c -i expir` → 0), so there was no glossary term to reuse and, per AGENTS.md, a coinage had to be proposed rather than assumed. The forcing function is the options track: `kestrel-ih5h` (the fomc-straddle WHIPSAW theta cell) and `kestrel-qga2` (the options-native cube, the headline RL curriculum) need a leg to name its own tenor. ## Decision 1. **A leg may carry its own expiry, marked by `exp`.** `buy 1 atm C exp 2026-07-17`, `buy 1 +2 C exp weekly`, `buy 1 +1 P exp 1dte`. Two tenors may coexist on one ticket. 2. **One expiry language, not two.** The selector after `exp` is the existing `ExpirySelector`, parsed by the **same** `parseExpiry` and printed by the **same** `expiryStr` that `USING exec SPY 0dte` already uses. No new selector syntax is coined — only the marker. 3. **Absent expiry means INHERIT the ambient `USING exec` tenor.** Inheritance is the default and resolves at execution. A missing expiry is **never** a silent `0dte`: the ambient tenor is a real, authored value, and inheriting it is a decision, not a default. 4. **A bare `exp` with no selector is a parse escape → STAND_DOWN**, carrying a machine-readable reason that names every legal form. Fail closed; never a silent inherit. 5. **The marker is required rather than positional.** Mirroring the positional instrument form (`USING exec SPY 0dte`) is unsafe on a leg: a leg is followed by `,` or `@`, and `parseExpiry`'s tag branch calls `readName`, so a bare positional tag would silently swallow the following token. The instrument form survives positionally only because a `USING_STOP` stop-set fences it. A marker is the minimum that keeps the form unambiguous. `exp` is unused elsewhere in the grammar, is the natural abbreviation of the `expiry` / `ExpirySelector` the codebase already says, and stays token-efficient. 6. **Designed twice, per OSS-ADR-0004** (the typed TS interface is the language; text is a projection): the builder form is `b.buy(qty, strike, right, expiry?)` / `b.sell(...)`, the text form is the `exp` tail. `print(parse(text))` stays byte-stable, and the feature is **purely additive** — the `exp` tail is emitted only when `expiry !== undefined`, so every pre-existing plan prints byte-identical bytes and the existing golden fixtures are unmodified. 7. **`#execFor` widens from symbol-only to a `(symbol, expiry)` key**, so `using.exec.expiry` is no longer silently dropped and a contradicting tenor cannot fill. 8. **DEFERRED, deliberately: re-keying the book.** Re-keying `foldBook` / `BookState` / the sim's `#books` by `(symbol, expiry)`, and turning `mark-to-model.ts`'s expiry assertion into a real multi-tenor lookup, is a **separate change**, sequenced after `kestrel-wcnd` (the tau-from-expiry fix) merges — wcnd is concurrently threading `BookState.expiry` as a scalar, and re-shaping it simultaneously would collide both textually and semantically. ## Consequences - A leg can name its tenor, which is what the options cell-expansion (`ih5h`) and the options-native cube (`qga2`) require. - **The cross-expiry contamination remains latent until decision 8 lands.** Authoring a second tenor is now expressible *before* the book can faithfully represent it; the `mark-to-model.ts` tripwire is what surfaces the boundary loudly rather than silently. This ADR does not close that hole — it names it and sequences it. - Zero-padding is preserved through the round-trip (`2026-1-5` stays `2026-1-5`): `parseExpiry` keeps the raw digit text, and normalization happens only at comparison time, never in the AST. - `CONTEXT.md` gains **Expiry selector** and **`exp` (the leg expiry marker)**; the vocabulary is now glossary-governed rather than ad hoc. - Because `exp` is on the language's public surface, it is materially cheaper to revise now than after the OSS/0.8.0 cut. ## Alternatives considered - **Positional, mirroring `USING exec SPY 0dte`.** Rejected: unsafe on a leg (decision 5) — it would silently swallow the following token, and a silent swallow is exactly the class of failure the fail-closed rule exists to prevent. - **Instrument-level tenor only (no per-leg expiry).** Rejected: a straddle/calendar cell cannot express two tenors on one ticket, which blocks `ih5h`/`qga2`. - **Default a missing expiry to `0dte`.** Rejected outright: a silent default is a silent lie about what the author asked for, and 0DTE is the *most* dangerous thing to assume. - **A longer keyword (`expiry`).** Rejected on token-efficiency grounds; `exp` is the codebase's own abbreviation and the language is optimized for agent authorship. ## References - OSS-ADR-0004 — the typed TS interface is the language; text is a projection (designed twice). - `kestrel-ih5h` (options cell-expansion + engine seams), `kestrel-qga2` (options-native cube). - `kestrel-wcnd` — tau from real expiry; decision 8 sequences after it. - `src/lang/ast.ts` (`OptionLeg.expiry`, `ExpirySelector`), `src/lang/parse.ts` (`parseExpiry`), `src/engine/plans.ts` (`#execFor`), `src/bus/types.ts` (`foldBook` / `BookState`).