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.

820 lines (765 loc) 72.3 kB
/** * # adapters/broker — the venue-agnostic broker/feed adapter SEAM (kestrel-7o2.4, increment 1) * * The charter claim this module proves: **`sim | paper | live` is ONE Session path where only the * gate behind the seam differs** (CONTEXT: Mode — "one engine path; only the gate differs"; sim.ts * §"One code path, one gate"). This file names the two seams that make that true and the mode-keyed * factory that selects a gate — WITHOUT any IBKR code, any network, any npm dependency, or any * real-money path (that is a later increment). * * ## The execution seam is ALREADY the Gate (engine/plans.ts) * The engine hands the gate an {@link OrderIntent} that is **fully resolved before it arrives**: a * numeric `px` (a silent mid is forbidden, RUNTIME §4), never-naked enforced, the price-resolution * receipt (`sourceAnnotation`) attached, the directional-guard evidence (`moneyness`/`covered`) * threaded. So a broker adapter is a **TRANSMITTER, never a re-pricer** — it routes the intent to a * venue and reports back what the venue did. A {@link BrokerAdapter} IS a {@link Gate}. * * ## How the reference gate (SimGate, session/sim.ts) surfaces ORDER events — the pattern to MIRROR * `SimGate.submit` is SYNCHRONOUS: it rests the intent in the {@link SimFillEngine} (the judge) and * returns a `string` ref. The fill engine APPENDS its typed ORDER events (`place | fill | reject | * cancel`) to a cumulative `events` buffer; an injected `drain()` closure slices the fresh ones onto * the emitted bus (stamping `seq`). The engine learns of fills ONLY by OBSERVING `ORDER fill` bus * events — `SessionCore.step` feeds each drained fill back via `engine.onEvent(fillEvent)` (the * fills-before-sweep ordering, RUNTIME §7). The engine never simulates a fill itself. * * A {@link BrokerAdapter} mirrors this EXACTLY (it does not invent a divergent event path): it holds * an `events` buffer of seq-less {@link NewBusEvent}s, its `submit`/`cancel` append `place | fill | * reject | cancel` records to it, and the SAME injected `drain()` surfaces them + feeds fills back to * the engine. The {@link OrderAction} set is CLOSED (`place | cancel | fill | reject`, bus/types.ts, * guarded by `isValidStreamType`) — an adapter stays within it. * * ## The FEED seam is the bus itself * There is NO abstract feed interface in the runtime today: `SessionCore.step(ev: BusEvent)` consumes * the {@link BusEvent} union directly (a `META` header + `TICK/BOOK` carrying the real * `OptionQuote[]` legs + `underlier_px` + `TICK/SPOT` carrying `px`/`bid`/`ask`). A {@link FeedSource} * is therefore exactly a **producer of that SAME union** — the mock replays a deterministic in-memory * sequence; a live feed would translate a venue's market-data stream into the identical shapes. No new * event type is introduced, so `SessionCore.step` folds a FeedSource's output with zero changes. * * ## Mode is first-class, and LIVE fails closed * `Mode` is `sim | paper | live` (bus/types.ts); the record layer already branches on it (`fidelityOf`, * `instanceIdentityOf`). The protocol authorization scopes DELIBERATELY EXCLUDE `broker`/`live` from * {@link WALLET_SIGNABLE_SCOPES} (protocol/index.ts) — **live authority requires a human signature, never * a wallet/config alone**. {@link makeGate} encodes exactly that boundary: `sim` returns a SimGate * (byte-identical to today), `paper` returns a {@link BrokerAdapter}-backed gate, and `live` FAILS CLOSED * with a typed {@link LiveGateRefused} UNLESS an explicit, human-authorized {@link LiveArm} is present. * **This increment provides NEITHER a live arm NOR live routing** — so the only correct outcome for `live` * here is the typed refusal. * * @remarks Increment 1 is the SEAM + the fail-closed factory. The green implementation (the reference * paper adapter's fill model, the SimGate construction, the live arm's signature verification) lands * behind these shapes; the stubs below throw {@link NotImplemented} until then. */ import type { Gate, OrderIntent } from "../engine/index.ts"; import type { BusEvent, Mode, NewBusEvent, OrderAction, Right } from "../bus/index.ts"; import type { SimFillEngine, SpotFillEngine } from "../fill/index.ts"; // The reference `sim` gate. It IS the venue face for `sim` mode (a Gate), so it lives behind the same // factory as the paper adapter — `makeGate("sim", …)` returns THIS exact class, byte-identical to the // hardwired `new SimGate(...)` SessionCore used before (a pure refactor). Value import (constructed // below); the sim.ts↔adapters edge is a runtime-only cycle (each side names the other solely inside a // function body / a constructor, never at module-init), so ESM resolves it with live bindings. import { SimGate } from "../session/sim.ts"; /** A seq-less ORDER event — the exact `NewBusEvent` variant {@link SimFillEngine} appends to its * cumulative log; the owner (the emitted stream via the injected `drain`) stamps `seq`. */ type NewOrderEvent = Extract<NewBusEvent, { stream: "ORDER" }>; // ───────────────────────────────────────────────────────────────────────────── // (1) BrokerAdapter — a Gate-shaped venue face // ───────────────────────────────────────────────────────────────────────────── /** * A venue-agnostic broker adapter (kestrel-7o2.4). A BrokerAdapter **IS a {@link Gate}** — the one * execution seam (engine/plans.ts): `submit` transmits an ALREADY-RESOLVED {@link OrderIntent} to the * venue and returns the ref the engine correlates later `ORDER` events against; `cancel` pulls a * resting order. Because the intent arrives fully priced + never-naked-checked + receipt-bearing, the * adapter is a **transmitter, never a re-pricer** (it must not touch `intent.px`). * * ORDER events (`place | fill | reject | cancel`) it produces are surfaced back to the engine EXACTLY * as {@link SimFillEngine} surfaces the sim judge's: appended to {@link events} (seq-less * {@link NewBusEvent}s), then an injected `drain()` closure stamps them onto the emitted bus and feeds * `fill`s back via `engine.onEvent(fillEvent)`. The adapter never invents a divergent event path, and * every event it emits inhabits the CLOSED `OrderAction` vocabulary. */ export interface BrokerAdapter extends Gate { /** The injected clock (epoch ms) the driver pins before each event — the gate seam is `now`-less * (RUNTIME §0); the driver owns the clock and pins it, exactly as `SessionCore` pins `SimGate.now`. * Stamped onto the ORDER events this adapter emits. */ now: number; /** Transmit a resolved intent to the venue; return the venue ref (the engine correlates fills * against it). MUST NOT re-price — `intent.px` is the resolved limit. */ submit(intent: OrderIntent): string; /** Pull a resting order by its ref. A no-op on an unknown/already-terminal ref (fail-closed, never * a crash). */ cancel(ref: string): void; /** The cumulative typed ORDER events this adapter has produced, in order — seq-less * {@link NewBusEvent}s mirroring {@link SimFillEngine.events}. The owner (the emitted stream via the * injected `drain`) stamps `seq`; a `fill` here is what the engine OBSERVES through * `engine.onEvent`. Read-only. */ readonly events: readonly NewBusEvent[]; /** The venue's AUTHORITATIVE position PULL (kestrel-7o2.9) — a queryable snapshot of the broker's * own reported net position per {@link PositionKey}, the source of truth the reconciliation-trip * compares the engine's EXPECTED positions against (ADR-0034 §4: "reconciliation must anchor to the * broker's AUTHORITATIVE PULL, not only push"). OPTIONAL: the reference sim/paper mock exposes it * (an in-memory net-of-fills query); a push-only transport that cannot pull is fail-closed at * reconcile time. Never re-prices — a read-only report. */ positions?(): PositionSnapshot; /** The venue's TRUE per-intent contract multiplier — dollars per point per contract (kestrel-7o2.8, * defense-in-depth). OPTIONAL: a venue that knows each contract's multiplier (the IBKR paper face * resolves it from its {@link BrokerAdapter} ContractBook — an option `100`, an equity `1`) exposes * it so the seam's {@link makeLiveClamp L0 clamp} computes `notional = px·qty·multiplier` on the REAL * per-contract notional, NOT a flat `1×` that is 100× too loose for a 100× option. `makeGate("paper")` * reads it STRUCTURALLY (the seam never imports a venue transport) and threads it into the clamp. A * venue that cannot resolve a multiplier omits this; the clamp then keeps its configured flat one. */ multiplierOf?(intent: OrderIntent): number; } /** Construction deps for the reference **mock/paper** {@link BrokerAdapter} (kestrel-7o2.4). Mirrors * how {@link SimFillEngine} + `SessionCore.#drain` are wired: `drain` surfaces this adapter's fresh * {@link BrokerAdapter.events} onto the emitted stream (and feeds `fill`s back to the engine) after * every `submit`/`cancel`, exactly as the SimGate closure does. Zero network. */ export interface PaperBrokerDeps { /** Surface this adapter's fresh ORDER events — the SimGate `() => void drain()` analogue. */ readonly drain: () => void; /** Contract multiplier (dollars per point per contract). Carried for parity with the fill engine; * the paper venue's deterministic fill model uses the intent's own `px` as the transmitted limit. */ readonly multiplier?: number; } /** Build one seq-less ORDER event from a resolved intent, mirroring {@link SimFillEngine}'s private * `#emit` field-for-field (`order_id`, optional `plan`/`plan_instance`, `instrument`, `side`, `qty`, * `strike`/`right` when present, then `px` + optional `reason`). The adapter NEVER invents a divergent * event path — every event inhabits the CLOSED {@link OrderAction} vocabulary and reads back through * the same projector the sim ORDER events do. */ function orderEvent( action: OrderAction, ts: number, intent: OrderIntent, extra: { readonly px: number; readonly reason?: string }, ): NewOrderEvent { return { ts, stream: "ORDER", type: action, order_id: intent.ref, ...(intent.plan !== undefined ? { plan: intent.plan } : {}), ...(intent.plan_instance !== undefined ? { plan_instance: intent.plan_instance } : {}), instrument: intent.instrument, side: intent.side, qty: intent.qty, ...(intent.strike !== undefined ? { strike: intent.strike } : {}), ...(intent.right !== undefined ? { right: intent.right } : {}), px: extra.px, ...(extra.reason !== undefined ? { reason: extra.reason } : {}), }; } /** * The reference **mock/paper** broker adapter (kestrel-7o2.4): a deterministic, in-memory, * ZERO-NETWORK venue that transmits an intent and reports a deterministic paper fill back as an * `ORDER fill` event — the full paper loop with no IBKR code, no wall clock, no RNG. * * It mirrors {@link SimFillEngine}'s ORDER-event flow EXACTLY: `submit` appends a `place` then a `fill` * (both at the intent's ALREADY-RESOLVED `px` — a TRANSMITTER, never a re-pricer) to its cumulative * {@link BrokerAdapter.events} buffer, then calls the injected {@link PaperBrokerDeps.drain} — the SAME * closure {@link SimGate} calls — which surfaces the fresh events onto the emitted bus and feeds the * `fill` back to the engine via `engine.onEvent`. Because the paper venue fills on submit, no order ever * rests, so `cancel` is a fail-closed no-op (never a crash). */ class PaperBroker implements BrokerAdapter { now = 0; readonly #events: NewOrderEvent[] = []; readonly #drain: () => void; constructor(deps: PaperBrokerDeps) { // `multiplier` is carried for parity with the fill engine; the paper venue's deterministic fill // model uses the intent's own `px` as the transmitted limit, so it never enters the price path. void deps.multiplier; this.#drain = deps.drain; } /** The cumulative typed ORDER events this adapter has produced, in order (read-only). A `fill` here * is what the engine OBSERVES through `engine.onEvent`; the injected `drain` stamps `seq`. */ get events(): readonly NewBusEvent[] { return this.#events; } submit(intent: OrderIntent): string { // TRANSMITTER (never-naked upstream, RUNTIME §4): transmit the resolved intent as a `place`, then // report the deterministic paper fill — both at `intent.px`, never a re-priced/mid anchor. Append // to the buffer FIRST, then drain, exactly as SimGate rests-then-drains. this.#events.push(orderEvent("place", this.now, intent, { px: intent.px })); this.#events.push(orderEvent("fill", this.now, intent, { px: intent.px, reason: "paper" })); this.#drain(); return intent.ref; // the ref the engine correlates the fill against (mirrors SimGate echoing the ref) } cancel(_ref: string): void { // The paper venue fills on submit, so nothing rests — a cancel is a fail-closed no-op. Still drains // (zero fresh events ⇒ harmless) to keep the rest-then-drain shape identical to SimGate.cancel. this.#drain(); } /** The venue's AUTHORITATIVE position PULL (kestrel-7o2.9): net-of-fills, signed (buy `+`, sell `−`) * per {@link PositionKey}, folded from this adapter's own `ORDER fill` records. Deterministic (no * clock/RNG) and read-only — the reconciliation-trip anchors to THIS, the broker's own truth, not the * engine's push stream (ADR-0034 §4). A push-only live transport that cannot pull would simply omit * this method; the paper double is fully queryable. */ positions(): PositionSnapshot { const snap: Record<PositionKey, number> = {}; for (const ev of this.#events) { if (ev.type !== "fill") continue; const key = positionKeyOf(ev); snap[key] = (snap[key] ?? 0) + (ev.side === "buy" ? ev.qty : -ev.qty); } return snap; } } /** * The reference **mock/paper** broker adapter (kestrel-7o2.4): a deterministic, in-memory, * ZERO-NETWORK venue that transmits an intent and reports a deterministic paper fill back as an * `ORDER fill` event — the full paper loop with no IBKR code. */ export function makePaperBroker(deps: PaperBrokerDeps): BrokerAdapter { return new PaperBroker(deps); } // ───────────────────────────────────────────────────────────────────────────── // (2) FeedSource — a producer of the existing BusEvent union // ───────────────────────────────────────────────────────────────────────────── /** * A venue-agnostic **feed** (kestrel-7o2.4). The runtime has no abstract feed interface: `SessionCore` * folds the {@link BusEvent} union directly. A FeedSource is exactly a producer of that SAME union — a * `META` header, `TICK/BOOK` with real `OptionQuote` legs, `TICK/SPOT` with `px`/`bid`/`ask` — so * `SessionCore.step` consumes it unchanged. The mock replays a deterministic in-memory sequence; a live * feed would translate a venue's market-data stream into the identical shapes. */ export interface FeedSource { /** The bus events this source produces, in order — the SAME union `SessionCore.step` consumes. */ events(): Iterable<BusEvent>; } /** The reference **mock** feed (kestrel-7o2.4): replays a fixed in-memory {@link BusEvent} sequence — * the SAME union `SessionCore.step` already folds, so it consumes the feed with zero changes and no new * event type. Deterministic (no clock/RNG): re-iterable, byte-stable across calls. A live feed is a * drop-in that yields the identical union off a venue's market-data stream. */ class ReplayFeed implements FeedSource { readonly #events: readonly BusEvent[]; constructor(events: readonly BusEvent[]) { this.#events = events; } events(): Iterable<BusEvent> { return this.#events; } } export function replayFeed(events: readonly BusEvent[]): FeedSource { return new ReplayFeed(events); } // ───────────────────────────────────────────────────────────────────────────── // (3) The mode-keyed gate factory — one Session path, only the gate differs // ───────────────────────────────────────────────────────────────────────────── /** The gate mode (kestrel-7o2.4) — the same closed {@link Mode} vocabulary the bus/record layer keys * on (`sim | paper | live`). */ export type GateMode = Mode; /** * The **risk limits** one {@link LiveArm} authorizes (kestrel-7o2.9) — a plain typed config carried ON * the arm, checked by the {@link makeLiveClamp L0 pre-transmit clamp} BEFORE any order reaches the wire * (ADR-0034 §4). All three are hard ceilings; over ANY of them ⇒ a typed {@link ClampRefused}, never a * transmit (fail-closed / bounded-risk, RUNTIME §8). The broker's own margin check is NOT the risk * boundary — L0 sits ABOVE the adapter (ADR-0034 §7). */ export interface RiskLimits { /** Max single-order size (contracts/shares) — `intent.qty` may not exceed it. */ readonly maxOrderQty: number; /** Max ABSOLUTE net position per {@link PositionKey} AFTER the order (given current positions). */ readonly maxPositionQty: number; /** Max notional per order in dollars: `px × qty × multiplier` may not exceed it. */ readonly maxNotionalUsd: number; } /** The UNBOUNDED paper-clamp limits (kestrel-7o2.21) — used when `makeGate("paper", …)` is called with * NO {@link PaperGateDeps.limits} config. Every ceiling is `+Infinity`, so no order is ever refused for * size/position/notional, yet the paper gate is STILL a composed {@link LiveClamp} (the kill-switch and * reconciliation-trip remain reachable) rather than the bare adapter. This keeps the pre-clamp paper * path's emitted bytes unchanged while making the L0 envelope a real, reachable call site. */ export const UNBOUNDED_PAPER_LIMITS: RiskLimits = { maxOrderQty: Number.POSITIVE_INFINITY, maxPositionQty: Number.POSITIVE_INFINITY, maxNotionalUsd: Number.POSITIVE_INFINITY, }; /** * A **human-signed grant** of live authority over a specific {@link RiskLimits} (kestrel-7o2.9). Live * is NOT wallet-signable — the protocol excludes `broker`/`live` from {@link WALLET_SIGNABLE_SCOPES} * (`src/protocol/index.ts`), so a wallet/config alone can never arm live; a human signature is * required. This is the RAW claim a caller presents; {@link armLive} verifies its signature through an * {@link AuthoritySigner} and only then mints the unforgeable {@link LiveArm}. A grant is a plain * object — it is NOT itself authority; presenting one proves nothing until the signature verifies. */ export interface LiveAuthorityGrant { /** Must be the non-wallet-signable `"live"` scope (protocol/index.ts). */ readonly scope: "live"; /** The risk limits this human signature authorizes — the signature is OVER these limits, so the * limits cannot be widened after signing without invalidating the signature. */ readonly limits: RiskLimits; /** Opaque handle to the human signature over `limits` (never a key/routine — mirrors the protocol * `CertifiedReceipt.signatureRef` open/closed seam). */ readonly signatureRef: string; } /** * Verifies the human signature on a {@link LiveAuthorityGrant} (kestrel-7o2.9) — the open/closed seam * that keeps the live-authority proof out of this module. **Production** wires a real signature * verifier (the human wallet/HSM signature over the canonical limits); **tests** wire a mock signer * over a deterministic {@link sha256} of the limits. {@link armLive} calls `verify` and fails closed if * it returns `false` — a grant whose signature does not verify can NEVER mint an arm. */ export interface AuthoritySigner { verify(grant: LiveAuthorityGrant): boolean; } /** * The explicit, human-authorized Kestrel-level **LIVE arm** (kestrel-7o2.4 declared it; kestrel-7o2.9 * makes it REAL and UNFORGEABLE). It is an OPAQUE, BRANDED token — the exported name is a type alias to * a class ({@link LiveArmToken}) with a private `#` brand, and the class value is NOT exported. Two * walls make it unforgeable: * * 1. **Nominal brand (compile-time).** The private `#brand` field makes a plain object literal NOT * structurally assignable to `LiveArm` — a forger must reach for `as unknown as LiveArm`, which the * runtime wall then catches. * 2. **Construction guard (run-time).** The only mint is {@link armLive}, which verifies a human * signature ({@link AuthoritySigner}) before `new`-ing the token. Nothing else can construct it, so * {@link isLiveArm} (a `#brand in x` check) rejects every fabricated value. * * The arm CARRIES the {@link RiskLimits} it authorizes (read via {@link armLimits}); those limits are * what the human signed over, so a caller cannot self-grant wider limits by hand-rolling an arm. Live * authority is NOT wallet-signable ({@link WALLET_SIGNABLE_SCOPES} excludes `broker`/`live`). */ export type LiveArm = LiveArmToken; /** The unforgeable {@link LiveArm} token (kestrel-7o2.9). NOT exported — the private `#brand` field * blocks structural forgery AND the class value is unreachable outside this module, so {@link armLive} * (signature-verified) is the sole mint. */ class LiveArmToken { /** Nominal, private brand — no object literal can carry it, so `#brand in x` is a forgery-proof * runtime check and the type is not structurally assignable from a plain object. */ readonly #brand = true; constructor( /** The risk limits the human signature authorized. */ readonly limits: RiskLimits, /** The verified grant this arm was minted from (its `signatureRef` is the authority receipt). */ readonly grant: LiveAuthorityGrant, ) { void this.#brand; } /** The forgery-proof runtime brand check — true iff `x` was minted by {@link armLive}. */ static has(x: unknown): x is LiveArmToken { return typeof x === "object" && x !== null && #brand in x; } } /** The sim-gate ingredients {@link makeGate} needs to build the reference SimGate for `sim` mode — * the SAME pieces `SessionCore` hands `new SimGate(...)` today, so the `sim` path stays byte-identical. * (`spotAllowed` = only the strict-cross family has a spot judge; `drain` surfaces fills, RUNTIME §7.) */ export interface SimGateDeps { readonly fill: SimFillEngine; readonly spotFill: SpotFillEngine; readonly spotAllowed: boolean; readonly drain: () => void; } /** * The paper-mode L0 clamp config (kestrel-7o2.21) — the carrier for the {@link makeGate}(`"paper"`) * safety envelope. It supplies the {@link RiskLimits} (and multiplier/tolerance/kill-switch/position * readers) the paper clamp enforces, sourced from CONFIG rather than a human signature — paper is * deliberately NOT human-signable. `makeGate` mints a {@link PaperArm} from these limits (or uses the * supplied {@link arm}) and wraps `deps.broker` in a {@link makeLiveClamp L0 clamp}. Absent ⇒ the paper * gate is still CLAMPED (never the bare adapter) but with UNBOUNDED limits, so its bytes are unchanged * from the pre-clamp paper path while the kill-switch/reconciliation-trip remain reachable. */ export interface PaperGateDeps { /** Config-supplied risk ceilings the paper clamp enforces. Absent ⇒ unbounded (no size/position/ * notional refusal), but the clamp is still composed (kill-switch + reconciliation reachable). */ readonly limits?: RiskLimits; /** A pre-minted {@link PaperArm} (from {@link makePaperArm}); overrides {@link limits} if present. * Deliberately NOT a {@link LiveArm} — a paper gate can never carry live authority. */ readonly arm?: PaperArm; /** Contract multiplier for `notional = px·qty·multiplier`. Absent ⇒ `1`. */ readonly multiplier?: number; /** Reconciliation tolerance (absolute qty per key). Absent ⇒ `0`. */ readonly tolerance?: number; /** The kill-switch to consult/trip. Absent ⇒ the clamp mints a fresh one. */ readonly killSwitch?: KillSwitch; /** Engine/Blotter EXPECTED positions for the max-position check + reconcile. Absent ⇒ the broker's * own PULL ({@link BrokerAdapter.positions}) if it exposes one, else empty. */ readonly positions?: () => PositionSnapshot; /** The broker's AUTHORITATIVE position PULL for the reconciliation-trip. Absent ⇒ the broker's own * {@link BrokerAdapter.positions} if it exposes one, else empty. */ readonly brokerPositions?: () => PositionSnapshot; } /** Everything the mode-keyed factory may need. Exactly one branch is exercised per mode; the others * are absent (a construction-order convenience, like `ComposedProvider`). */ export interface GateDeps { /** The SimGate ingredients — required for `sim`. */ readonly sim?: SimGateDeps; /** The venue adapter — required for `paper` (and, in a later increment, `live`). */ readonly broker?: BrokerAdapter; /** The paper L0 clamp config (kestrel-7o2.21) — the {@link RiskLimits}/multiplier/tolerance the * `paper` gate's {@link makeLiveClamp clamp} enforces. Absent ⇒ an unbounded-but-still-composed clamp * (the paper gate is NEVER the bare adapter). NOT human-signable — paper authority is config, not a * signature. */ readonly paper?: PaperGateDeps; /** The explicit human-authorized live arm — the ONLY thing that could unlock `live`. Absent in this * increment ⇒ `makeGate("live", …)` fails closed with {@link LiveGateRefused}. Accepts an EXPLICIT * `undefined` (not just omission): "no arm" is a first-class fail-closed input a caller may state * outright, and it must refuse identically to omitting it (exactOptionalPropertyTypes). */ readonly liveArm?: LiveArm | undefined; /** The PAPER VENUE to serve this gate from (kestrel-7o2.8) — the name a venue adapter registered * itself under via {@link registerPaperVenue} (e.g. `"ibkr"`). An alternative to handing * {@link broker} directly: it lets `makeGate("paper", …)` reach a venue face WITHOUT this module * importing that venue's transport (so no npm/socket dependency ever lands on the `sim` path). An * UNREGISTERED name is refused, never a silent fallthrough. */ readonly venue?: PaperVenue | undefined; } // ── the paper-venue registry (kestrel-7o2.8) ───────────────────────────────── /** The name a paper venue adapter registers itself under (e.g. `"ibkr"`). */ export type PaperVenue = string; /** How {@link makeGate} reaches a registered paper venue — a THUNK, so the venue's adapter (and its * socket/npm dependencies) is only touched when a `paper` gate actually asks for it. */ export type PaperVenueFactory = () => BrokerAdapter; const PAPER_VENUES = new Map<PaperVenue, PaperVenueFactory>(); /** * Register a venue adapter as the {@link BrokerAdapter} `makeGate("paper", { venue })` serves * (kestrel-7o2.8). The venue's own module calls this at its composition root, so THIS module never * imports a venue transport — `sim` keeps its zero-dependency path, byte-identically. Registration is * PAPER-ONLY by construction: nothing here can unlock `live`, which still requires the human-signed * {@link LiveArm} and refuses with {@link LiveGateRefused} regardless of what is registered. */ export function registerPaperVenue(venue: PaperVenue, make: PaperVenueFactory): void { PAPER_VENUES.set(venue, make); } /** The paper venues registered so far, sorted (a stable, loggable list — never an RNG/insertion-order * leak into a diagnostic). */ export function registeredPaperVenues(): readonly PaperVenue[] { return [...PAPER_VENUES.keys()].sort(); } /** Resolve a registered paper venue, or refuse. An UNREGISTERED name is a LOUD error, never a silent * fallthrough to some other gate (fail-closed). Absent name ⇒ `undefined` (the caller then requires * an explicit {@link GateDeps.broker}). */ function resolvePaperVenue(venue: PaperVenue | undefined): BrokerAdapter | undefined { if (venue === undefined) return undefined; const make = PAPER_VENUES.get(venue); if (make === undefined) { const known = registeredPaperVenues(); throw new Error( `makeGate: mode 'paper' names the UNREGISTERED venue ${JSON.stringify(venue)} (registered: ${known.length === 0 ? "none" : known.join(", ")}) — fail-closed, never a silent fallthrough`, ); } return make(); } // ── ONE shared kill-switch + TWO independently-proven ceilings (kestrel-7o2.8, defense-in-depth) ──── // // A venue adapter MAY already carry its own {@link KillSwitch}, {@link RiskLimits}, and per-contract // multiplier (the IBKR paper face does — its inbound pump trips a switch the moment the venue's own // report proves a never-naked break, and it resolves each contract's true multiplier). The design here // is deliberately defense-in-depth, and the two axes are NOT symmetric: // // • KILL-SWITCH — exactly ONE, SHARED. `makeGate("paper", …)` reads the venue's own switch // (structurally — the seam never imports a venue module) and threads it INTO the clamp, so a trip // in EITHER layer (an operator STAND_DOWN on the seam, or the adapter's at-observation never-naked // trip) halts the WHOLE path. A second independent switch would leave a trip unable to reach the // other layer — the hole this closes. // • RISK CEILINGS — TWO layers, each INDEPENDENTLY LOAD-BEARING (ADR-0034 §4 defense-in-depth): the // SEAM clamp (this makeLiveClamp, L0) and the adapter's own venue-boundary WALL 5. The seam reads // the venue's SAME numeric limits + true per-contract multiplier and threads them in, so the two // never drift to divergent numbers — but neither is a mere backstop to a single "canonical owner": // a bare adapter (no seam) must still refuse, and a bare seam over a WALL-5-less adapter must still // refuse. Each is proven load-bearing by a mutation test that guts it and watches a test go RED. /** Structural {@link KillSwitch} probe — true iff `x` presents the switch's read-and-trip surface. */ function isKillSwitch(x: unknown): x is KillSwitch { return ( typeof x === "object" && x !== null && typeof (x as { tripped?: unknown }).tripped === "boolean" && typeof (x as { trip?: unknown }).trip === "function" ); } /** Structural {@link RiskLimits} probe — true iff `x` carries all three numeric L0 ceilings. */ function isRiskLimits(x: unknown): x is RiskLimits { return ( typeof x === "object" && x !== null && typeof (x as { maxOrderQty?: unknown }).maxOrderQty === "number" && typeof (x as { maxPositionQty?: unknown }).maxPositionQty === "number" && typeof (x as { maxNotionalUsd?: unknown }).maxNotionalUsd === "number" ); } /** The venue adapter's OWN kill-switch, if it self-guards (e.g. the IBKR paper face) — else `undefined` * (the reference mock/paper broker has none, so the clamp mints a fresh one, unchanged). Read * structurally so the seam never imports a venue transport. */ function venueKillSwitchOf(broker: BrokerAdapter): KillSwitch | undefined { const k = (broker as { killSwitch?: unknown }).killSwitch; return isKillSwitch(k) ? k : undefined; } /** The venue adapter's OWN configured L0 ceilings, if it exposes them — so the seam clamp enforces the * SAME limits (canonical, above the adapter) instead of an UNBOUNDED no-op. `undefined` for a venue * that carries none. Read structurally so the seam never imports a venue transport. */ function venueLimitsOf(broker: BrokerAdapter): RiskLimits | undefined { const l = (broker as { limits?: unknown }).limits; return isRiskLimits(l) ? l : undefined; } /** The venue adapter's OWN per-intent contract multiplier resolver, if it exposes one (kestrel-7o2.8) — * the IBKR paper face resolves it from its ContractBook (an option `100`, an equity `1`), so the seam * clamp computes `notional = px·qty·multiplier` on the venue's TRUE per-contract multiplier rather than * a flat `1×` that is 100× too loose for a 100× option. `undefined` for a venue that carries none (the * reference mock/paper broker), so the clamp keeps its configured flat multiplier. Read structurally so * the seam never imports a venue transport; the returned closure re-binds to the broker on each call. */ function venueMultiplierOf(broker: BrokerAdapter): ((intent: OrderIntent) => number) | undefined { const m = (broker as { multiplierOf?: unknown }).multiplierOf; if (typeof m !== "function") return undefined; return (intent: OrderIntent): number => (broker as Required<Pick<BrokerAdapter, "multiplierOf">>).multiplierOf(intent); } /** * The typed, fail-closed refusal {@link makeGate} raises for a `live` gate requested without an explicit * {@link LiveArm} (kestrel-7o2.4). A DISTINCT error class (not a bare `Error`) so a caller can catch the * live-authority refusal specifically and never mistake it for an unrelated failure — and so a `live` * request can NEVER silently fall through to a routing gate. Mirrors the protocol boundary: `live` is not * in {@link WALLET_SIGNABLE_SCOPES}; live authority requires a human signature (RUNTIME §8, fail-closed). */ export class LiveGateRefused extends Error { readonly mode = "live" as const; constructor(reason: string) { super(reason); this.name = "LiveGateRefused"; } } /** Thrown by the reference stubs until the green implementation lands (kestrel-7o2.4 increment 1 is the * SEAM + the fail-closed factory; the behavior behind the shapes is a later increment). Distinct from * {@link LiveGateRefused} so a RED test can tell "not yet built" from "live refused by design". */ export class NotImplemented extends Error { constructor(what: string) { super(`kestrel-7o2.4: ${what} is not implemented in this increment (SEAM + RED phase)`); this.name = "NotImplemented"; } } /** * Select the execution gate for a mode (kestrel-7o2.4) — the ONE line the hardwired * `this.gate = new SimGate(...)` (session/sim.ts) becomes, proving `sim | paper | live` is one Session * path where only the gate differs: * * - `sim` → the reference SimGate over {@link GateDeps.sim} — BYTE-IDENTICAL to today. * - `paper` → the {@link BrokerAdapter} in {@link GateDeps.broker} (a BrokerAdapter IS a Gate). * - `live` → FAILS CLOSED with {@link LiveGateRefused} unless {@link GateDeps.liveArm} is present. * This increment provides no arm and no routing, so `live` ALWAYS refuses here. * * The `sim` overload returns the concrete {@link SimGate} (its `now` is pinned by the driver each event), * so SessionCore keeps its `readonly gate: SimGate` field and this stays a pure refactor. * * ## The `paper` gate's L0 GUARANTEE — self-sufficient, never delegated (ADR-0034 §4, kestrel-ct9m) * * `makeGate("paper", …)` NEVER returns the bare adapter: it returns the {@link makeLiveClamp L0 clamp} * wrapped around it. The clamp's contract holds over a transport with NO walls of its own (the reference * mock today; a future live transport with no venue-side validation tomorrow) — "a bare seam over an * adapter with no WALL 5 still refuses". Concretely, BEFORE anything is transmitted, `submit` refuses with * a typed {@link ClampRefused} when ANY of these holds: * * - the {@link KillSwitch} is tripped ⇒ `"killed"` * - `qty` or `px` is NON-FINITE (NaN/±Infinity) ⇒ `"not-a-number"` * - `qty` is not a POSITIVE INTEGER, or `px` is not POSITIVE ⇒ `"malformed-intent"` * - the contract multiplier is non-finite or non-positive ⇒ `"multiplier"` * - `qty` exceeds `maxOrderQty` ⇒ `"order-size"` * - `|projected net position|` exceeds `maxPositionQty` ⇒ `"position"` * - `px·qty·multiplier` exceeds `maxNotionalUsd` ⇒ `"notional"` * * The WELL-FORMEDNESS rows are load-bearing, not hygiene: every ceiling above them is a `>` comparison, * and a `>` comparison against a NaN, a negative, or a zero is `false` — i.e. it PASSES. Without those * rows a `qty = -1_000_000` sell clears the size ceiling, drives the notional negative past * `maxNotionalUsd`, and sign-inverts the projected position. Validity is checked HERE and not deferred to * the venue face's own WALL 1, because on a bare seam there is no WALL 1 to defer to. */ export function makeGate(mode: "sim", deps: GateDeps): SimGate; export function makeGate(mode: GateMode, deps: GateDeps): Gate; export function makeGate(mode: GateMode, deps: GateDeps): Gate { switch (mode) { case "sim": { // BYTE-IDENTICAL to the hardwired `new SimGate(this.fill, this.spotFill, …, () => void this.#drain())` // — the same class over the same ingredients, so the sim path's emitted bytes never move. const sim = deps.sim; if (sim === undefined) { throw new Error("makeGate: mode 'sim' requires deps.sim (the SimFillEngine ingredients) — none present (fail-closed)"); } return new SimGate(sim.fill, sim.spotFill, sim.spotAllowed, sim.drain); } case "paper": { // The broker is resolved EITHER handed in directly (`deps.broker`, the reference mock/paper // double) OR through the venue REGISTRY (`deps.venue`, kestrel-7o2.8 — e.g. the IBKR paper face, // which registers itself so this module never imports its socket transport). An unregistered // venue is refused inside `resolvePaperVenue`; neither door can reach `live`. // The resolved broker is then CLAMPED (kestrel-7o2.21): the L0 pre-transmit clamp wraps the paper // broker so an over-limit / killed order is REFUSED before it reaches the venue. NEVER the bare // adapter. Authority is a PaperArm (config limits, not a human signature): paper carries no // real-money risk and is not human-signable, yet the clamp still records `provenance: "paper"` so // this gate can never be reached as a live gate. const broker = deps.broker ?? resolvePaperVenue(deps.venue); if (broker === undefined) { throw new Error( `makeGate: mode 'paper' requires deps.broker (a BrokerAdapter) or a registered deps.venue (registered: ${registeredPaperVenues().join(", ") || "none"}) — none present (fail-closed)`, ); } const cfg = deps.paper; // SINGLE KILL-SWITCH (kestrel-7o2.8). If the resolved venue adapter self-guards (the IBKR paper // face trips a switch from INSIDE its inbound pump on an observed never-naked break), that ONE // switch must ALSO be the switch this seam clamp consults — otherwise the clamp mints a second, // independent one and a trip in either layer can never reach the other (the double-kill-switch // hole). Thread the venue's own switch into makeLiveClamp so a trip in EITHER layer halts the // WHOLE path. An explicit `cfg.killSwitch` still wins (a caller wiring one shared switch by hand). const killSwitch = cfg?.killSwitch ?? venueKillSwitchOf(broker); // TWO INDEPENDENTLY-PROVEN L0 CEILINGS, DEFENSE-IN-DEPTH (ADR-0034 §4). This is NOT one canonical // owner with a subordinate backstop — it is a SEAM ceiling (this makeLiveClamp, L0) AND an adapter // venue-boundary ceiling (the IBKR face's WALL 5), each proven load-bearing on its own (a bare // adapter with no seam still refuses; a bare seam over an adapter with no WALL 5 still refuses). // Prefer the venue adapter's OWN configured ceilings so the SEAM clamp enforces the SAME numeric // limits — read from the venue so the two layers can never drift to divergent numbers. Absent both // ⇒ UNBOUNDED (the reference mock/paper broker), so that path's bytes stay unchanged. This retires // the "benign UNBOUNDED no-op that masks the seam": the seam ceiling is now real, not a pass-through. const limits = cfg?.limits ?? venueLimitsOf(broker) ?? UNBOUNDED_PAPER_LIMITS; const arm = cfg?.arm ?? makePaperArm(limits); const brokerPull = (): PositionSnapshot => broker.positions?.() ?? {}; // TRUE PER-CONTRACT MULTIPLIER (kestrel-7o2.8, the notional-100x fix). The seam clamp bounds // `notional = px·qty·multiplier`; on the registry path `deps.paper` is undefined, so before this // fix the multiplier defaulted to `1` and the seam's maxNotionalUsd ceiling was 100× too loose for // a 100× option — the adapter's WALL 5 was then the ONLY correct notional bound. Now the seam READS // the venue's own per-intent multiplier (option 100, equity 1) and threads it in, so the seam L0 // ceiling is computed on REAL notional. An explicit `cfg.multiplier` still wins; a venue that // exposes none (the reference mock/paper broker) falls back to `1`, unchanged. return makeLiveClamp({ underlying: broker, arm, multiplier: cfg?.multiplier ?? venueMultiplierOf(broker) ?? 1, positions: cfg?.positions ?? brokerPull, brokerPositions: cfg?.brokerPositions ?? brokerPull, ...(killSwitch !== undefined ? { killSwitch } : {}), ...(cfg?.tolerance !== undefined ? { tolerance: cfg.tolerance } : {}), }); } case "live": { // FAIL CLOSED. Live authority is NOT wallet-signable (the protocol excludes `broker`/`live` from // WALLET_SIGNABLE_SCOPES) — a human signature is required. Without an explicit arm the ONLY correct // outcome is the typed refusal; the `live` request can NEVER silently fall through to a routing gate. const arm = deps.liveArm; if (arm === undefined) { throw new LiveGateRefused( "live authority requires an explicit, human-signed Kestrel arm — none present (fail-closed; live is not wallet-signable, protocol/index.ts, RUNTIME §8)", ); } // MODE WALL (kestrel-7o2.21): the arm must be a signature-verified LiveArm. A PaperArm (config // authority) or any forgery is NOT a LiveArm, so it can NEVER unlock live — refuse fail-closed. This // is what makes a PaperArm paper-only: it authorizes the paper clamp, never a live gate. if (!isLiveArm(arm)) { throw new LiveGateRefused( "live authority requires a human-signed LiveArm minted by armLive — the supplied arm was not (a PaperArm or forgery can never authorize live, fail-closed)", ); } // A genuine LiveArm was supplied, but THIS increment ships no live routing (the seam is declared, // not wired). Honestly distinct from the by-design refusal above, and still never a live gate. throw new NotImplemented("live gate routing"); } default: { // Exhaustive over the closed Mode vocabulary — an unknown mode is refused, never a silent default. const never: never = mode; throw new Error(`makeGate: unknown mode ${JSON.stringify(never)} (fail-closed)`); } } } // ───────────────────────────────────────────────────────────────────────────── // (4) The SAFETY ENVELOPE (kestrel-7o2.9) — arm mint + L0 clamp + kill-switch + reconciliation-trip // // This is the per-process safety envelope that MUST land BEFORE any live-capable order-routing code // (ADR-0034 §4). It ships NO live routing / no venue transport — it is exercised in full against the // reference mock/paper {@link BrokerAdapter} (an in-memory broker double). The cross-process singleton // (the control-plane lease, ADR-0034 §7 / kestrel-7o2.2) is a SEPARATE bead — NOT built here. // // Increment status: SEAM + RED. The shapes below are declared; the stubs throw {@link NotImplemented} // until the green implementation lands. (Pure helpers — {@link positionKeyOf}, {@link isLiveArm}, // {@link armLimits} — are real, since there is no behavior to defer in a key/brand read.) // ───────────────────────────────────────────────────────────────────────────── /** A net-position key (kestrel-7o2.9): `` `${instrument}|${strike}|${right}` `` for an option leg, * bare `instrument` for a spot/equity leg (ADR-0017 — no fictional strike). The unit the L0 * max-position check and the reconciliation-trip both key on. */ export type PositionKey = string; /** A snapshot of SIGNED net position per {@link PositionKey} (kestrel-7o2.9) — buy `+`, sell `−`. Two * readings are compared at reconcile: the engine/Blotter EXPECTED snapshot vs the broker's * AUTHORITATIVE PULL ({@link BrokerAdapter.positions}). */ export interface PositionSnapshot { readonly [key: PositionKey]: number; } /** The {@link PositionKey} for an order/leg (kestrel-7o2.9) — pure, so the clamp, the engine-expected * snapshot, and the broker PULL all key identically (no skew). */ export function positionKeyOf(o: { readonly instrument: string; readonly strike?: number; readonly right?: Right }): PositionKey { return o.strike !== undefined && o.right !== undefined ? `${o.instrument}|${o.strike}|${o.right}` : o.instrument; } /** * Mint an unforgeable {@link LiveArm} from a human-signed {@link LiveAuthorityGrant} (kestrel-7o2.9) — * the SOLE construction path. Verifies the grant's signature through the injected {@link AuthoritySigner} * and, only if it verifies, `new`s the branded token carrying the signed {@link RiskLimits}. FAIL-CLOSED: * a grant whose signature does not verify (a wallet/config forgery, a widened-after-signing limit set) * throws {@link LiveGateRefused} — it can NEVER mint an arm. Live is not wallet-signable (protocol * excludes `broker`/`live` from {@link WALLET_SIGNABLE_SCOPES}); this is the human-signature wall. */ export function armLive(grant: LiveAuthorityGrant, signer: AuthoritySigner): LiveArm { // Wall 1 — the scope must be the non-wallet-signable `"live"` (protocol excludes it from // WALLET_SIGNABLE_SCOPES); a grant claiming any other scope can never be a live arm. if (grant.scope !== "live") { throw new LiveGateRefused( `armLive: a live arm requires scope "live" (got ${JSON.stringify(grant.scope)}) — fail-closed (live is not wallet-signable, protocol/index.ts)`, ); } // Wall 2 — the human signature over the EXACT limits must verify. A wallet/config forgery, or a limit // set widened after signing, fails here and can NEVER mint an arm (fail-closed, RUNTIME §8). if (!signer.verify(grant)) { throw new LiveGateRefused( "armLive: the human signature over the risk limits did not verify — a wallet/config forgery or a widened-after-signing limit set cannot mint a live arm (fail-closed)", ); } // Verified — mint the branded token carrying exactly the limits the human signed over. This is the // SOLE construction site (the class value is not exported), so `isLiveArm` is a total forgery check. return new LiveArmToken(grant.limits, grant); } /** The forgery-proof runtime brand check (kestrel-7o2.9) — true iff `arm` was minted by {@link armLive}. * A plain object literal (even one shaped like a grant, even cast `as unknown as LiveArm`) returns * `false`: the private `#brand` is unreachable except through the sole mint. REAL (not a stub) — a * brand read has no behavior to defer, and the clamp/factory rely on it to reject fabricated arms. */ export function isLiveArm(arm: unknown): arm is LiveArm { return LiveArmToken.has(arm); } /** The {@link RiskLimits} an {@link LiveArm} authorizes (kestrel-7o2.9) — the exact limits the human * signed over, read off the opaque token. REAL (not a stub). */ export function armLimits(arm: LiveArm): RiskLimits { return arm.limits; } /** * A **PAPER arm** (kestrel-7o2.21) — a distinct, BRANDED authority that carries config-supplied * {@link RiskLimits} for the paper venue WITHOUT any human signature. It is the paper-mode sibling of * {@link LiveArm}: same "an unforgeable token that carries limits" shape, but minted by a plain config * mint ({@link makePaperArm}) rather than the signature-verified {@link armLive}. This is what lets the * {@link makeLiveClamp L0 clamp} wrap the PAPER broker — the clamp becomes a REAL call site of the L0 * envelope instead of dead code — WITHOUT fabricating live authority. * * The wall that keeps this safe: a PaperArm is NOT a {@link LiveArm} ({@link isLiveArm} returns `false` * for it, its `#paperBrand` is a different private field than {@link LiveArmToken}'s `#brand`), so it can * NEVER authorize a live gate — `makeGate("live", …)` rejects it fail-closed, and a clamp built from a * PaperArm records `provenance: "paper"`. Paper is deliberately not human-signable; a PaperArm proves * only paper authority, never live. {@link armLive} remains the SOLE mint of {@link LiveArm}. */ export type PaperArm = PaperArmToken; /** The branded {@link PaperArm} token (kestrel-7o2.21). NOT exported — the private `#paperBrand` field * (distinct from {@link LiveArmToken}'s `#brand`) blocks structural forgery AND makes it un-confusable * with a LiveArm, so {@link makePaperArm} is the sole mint and {@link isPaperArm} is a total check. */ class PaperArmToken { /** Nominal, private brand — distinct from {@link LiveArmToken.prototype} so no PaperArm is ever an * accidental LiveArm; `#paperBrand in x` is a forgery-proof runtime check. */ readonly #paperBrand = true; constructor( /** The config-supplied risk limits this paper arm authorizes (NOT a human signature). */ readonly limits: RiskLimits, ) { void this.#paperBrand; } /** True iff `x` was minted by {@link makePaperArm}. */ static has(x: unknown): x is PaperArmToken { return typeof x === "object" && x !== null && #paperBrand in x; } } /** * Mint a {@link PaperArm} from plain config {@link RiskLimits} (kestrel-7o2.21) — the paper-mode * counterpart to {@link armLive}, but with NO signature verification, because paper carries no * real-money risk and is deliberately NOT human-signable. The minted arm is a branded token, so a * forged/literal object still cannot pass as authority (the {@link makeLiveClamp} construction guard * rejects anything neither {@link isLiveArm} nor {@link isPaperArm}). It authorizes ONLY paper: it is * not a {@link LiveArm} and can never unlock `makeGate("live", …)`. */ export function makePaperArm(limits: RiskLimits): PaperArm { return new PaperArmToken(limits); } /** The forgery-proof runtime brand check for a {@link PaperArm} (kestrel-7o2.21) — true iff `arm` was * minted by {@