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
Markdown
# 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`).