/** * # adapters/broker/ibkr — the IB Gateway PAPER ORDER FACE (kestrel-7o2.8) * * The first Kestrel module allowed to name `placeOrder`, and it may do so ONLY behind the 7o2.9 * safety envelope. It is a {@link BrokerAdapter} — therefore a {@link Gate} — so it drops into the * ONE execution seam the engine already fires through (`engine/plans.ts`), and `sim | paper | live` * stays one Session path where only the gate differs. * * ## A TRANSMITTER, never a re-pricer * The {@link OrderIntent} arrives FULLY RESOLVED: a numeric `px` the engine already earned (with its * `sourceAnnotation` receipt), never-naked-checked, directional-guard evidence attached. This adapter * therefore **never touches `intent.px`** — it does not round it, does not snap it "to improve", does * not substitute a mid (a mid is a HEALTH SIGNAL, never a price and never a value, RUNTIME §4). It * transmits that exact number as an IB `LMT` limit price, or it REFUSES. There is no third path. * * ## PAPER ONLY. `live` is refused at construction. * Live routing is kestrel-7o2.10 and lands only behind the human-signed {@link LiveArm}. A `live` * config here is a typed {@link IbkrOrderRefused} `mode-gate` — fail-closed, never a port fact. * * ## The walls, ALL of them BEFORE the wire * `submit` runs a guard chain and only then calls `placeOrder`. Any wall ⇒ a typed throw and * **`placeOrder` is never called** (the {@link IbOrderClient} spy in the tests is the witness): * * 0. **KILL-SWITCH** (7o2.9) — tripped (operator STAND_DOWN, or a reconciliation break) ⇒ * {@link ClampRefused} `killed`. Consulted first so a halted process refuses uniformly. On the * `makeGate("paper")` path this is the SAME switch the seam's L0 clamp consults (makeGate threads * it in): exactly ONE instance, so a trip in EITHER layer halts the whole path — never two * independent switches that cannot reach each other. * 1. **THE INTENT ITSELF** — `qty` a positive whole number, `px` a positive finite price, the `ref` * not already on the ledger. IB is never this system's validator: a SELL of `qty: -3` would * sign-invert its way through the never-naked wall and put `totalQuantity: -3` on the wire. * 2. **CONTRACT** — the leg must already be resolved to the gateway's OWN definition (7o2.6). An * unresolved leg is refused; an identity is NEVER guessed on the hot path. * 3. **NEVER-NAKED** — the projected position may never go NEGATIVE. It is measured against the * **BROKER's own reported truth MINUS the quantity our WORKING (unfilled) SELLs have already * promised away** — the worst case, once every order in flight has filled. A long option (max loss * = premium) and a long equity are defined risk; an uncovered short is unbounded risk, and no * budget makes it acceptable, so this is a doctrine refusal rather than a ceiling. * * A RESTING sell does not move broker truth until it fills. Reading truth alone therefore let a * SECOND sell of the same long clear this wall — and when both filled, this module opened an * uncovered short (kestrel-7o2.8, blocker 1). Hence the RESERVATION: a working sell holds its * quantity against the key from SUBMIT until a terminal outcome releases it. * 4. **INTRINSIC FLOOR** — a SELL is floored at intrinsic. Below it ⇒ refused. And because the * adapter may not re-price, an UNKNOWN intrinsic also refuses (the floor is never *assumed* * satisfied — that would be a silent default, and this runtime has none). * 5. **L0 CLAMP** (7o2.9 {@link RiskLimits}) — `maxOrderQty`, `maxPositionQty`, `maxNotionalUsd` * (`notional = px x qty x the VENUE's multiplier`). Over ANY ceiling ⇒ {@link ClampRefused}, and * NOTHING is transmitted. This is ONE of TWO INDEPENDENTLY-PROVEN ceilings, defense-in-depth * (ADR-0034 §4) — NOT a subordinate backstop to a single canonical owner. The OTHER is the seam's * `makeLiveClamp` ABOVE the adapter, which on the `makeGate("paper")` path wraps this face, SHARES * its ONE kill-switch, and enforces the SAME limits + the SAME true per-contract multiplier (all * threaded in via {@link IbkrBroker.limits} / {@link IbkrBroker.multiplierOf}). Each layer is proven * load-bearing on its own: this WALL 5 is the ONLY ceiling when the face is reached BARE (unit tests, * the env-gated dry-run/equity-order proof scripts — where no seam wraps it, and fail-closed forbids * a bare venue face with no ceiling), and a mutation test gutting it goes RED; the seam is the only * ceiling over a WALL-5-less adapter, and a mutation gutting the seam goes RED too. Same numbers, * never divergent — two barriers, not one owner with a shadow. * 6. **PRICE ANCHOR** ({@link PriceAnchor}) — the walls above bound SIZE; this one bounds the PRICE. * The order's `@fair` must lie INSIDE the venue's OBSERVED two-sided book, and that book must be * LIVE: delayed/frozen data is a HEALTH SIGNAL, never a price anchor. A fair above the ask (or * below the bid) means the ANCHOR ITSELF is lying, and no size ceiling can rescue a bad price — so * no authorizable ticket is produced at all. (Live, a ticket printed CLEARED at `@fair = 0.9229` * against an observed offer of `0.73`: "a BUY never bids above fair" did zero work, because fair * was above the offer. `@fair`'s own derivation is the engine's — kestrel-ku99 — but a consumer * that cannot corroborate its anchor must fail closed.) * * {@link IbkrBroker.preflight} runs walls 0–6 and returns the resolved {@link OrderTicket} **without * transmitting anything** — that is what the dry-run ticket prints, and it is why a ticket can be * reviewed by a human before a single byte reaches the venue. * * ## The inbound pump — IB callbacks → the EXISTING closed ORDER vocabulary * The engine already folds `ORDER place | fill | cancel | reject` (bus/types.ts, a CLOSED * {@link OrderAction} set). No new event kind is invented: * * - `openOrder` / a live `orderStatus` → **`place`** (the venue's acknowledgment), emitted at most * ONCE per ref. IB duplicates `orderStatus` routinely, so every mapping here is idempotent. * - `execDetails` → **`fill`**, one per NEW `execId`. This — not `orderStatus.filled` — is the fill * source of truth: `orderStatus` reports a CUMULATIVE count and repeats itself, while each partial * fill has its own `execId`. So PARTIAL and MULTI-fill both fall out for free, each at the price * the venue actually printed, and a replayed callback can never double-count. * - `orderStatus` `Cancelled`/`ApiCancelled` → **`cancel`**; `Inactive`, or an order-scoped fatal IB * `error`, → **`reject`**. Both terminal, both latched. * - `commissionReport` → the LEDGER only. It is not an ORDER event: the vocabulary is closed, and a * commission is not an order action. * * Causal order is preserved (a `fill` that somehow beats its acknowledgment still emits `place` * first), so a replay of the same callback sequence yields a byte-identical event stream. * * ## The reconciliation ledger — the BROKER's report is authoritative, and the trip fires AT OBSERVATION * {@link IbkrBroker.ledger} holds per-order state. {@link IbkrBroker.positions} is the venue's own * signed net position — its `position` push as a BASELINE, plus the executions observed SINCE it — * never the engine's expectation. The push is SUBSCRIBED at construction (`reqPositions`): IB does not * emit `position` unsolicited, and without the subscription "broker truth" is really just the engine's * own mirror wearing that label. * * The break TRIPS THE {@link KillSwitch} FROM INSIDE THE `execDetails` HANDLER — the moment the venue's * report proves an over-fill, a post-terminal fill, or ANY key gone negative. **An invariant is only * real if something calls it**: the shipped version could only trip via {@link IbkrBroker.reconcile}, * which nothing in production ever called, so an over-fill sat at −3 with the switch un-tripped and the * next submit still accepted (blocker 3). `reconcile()` survives as an explicit AUDIT — it compares the * engine/Blotter EXPECTED snapshot against broker truth — but it is no longer the only thing that can * trip. Never a silent divergence, and the engine never "corrects" the broker. * * ## Determinism at the edge * The IB client and the order-id minter are INJECTED, so the unit tests drive an in-memory double * with no socket, no timer, and no real order. Nothing here reads a wall clock: ORDER events are * stamped with the `now` the driver pins on the gate (RUNTIME §0), exactly as `SimGate` is. */ import { EventName, OrderType, TimeInForce, OrderStatus, OrderAction as IbOrderAction, SecType, OptionType, isNonFatalError } from "@stoqey/ib"; import type { Contract, Order, Execution, CommissionReport, OrderState } from "@stoqey/ib"; import type { BrokerAdapter, KillSwitch, PositionKey, PositionSnapshot, RiskLimits } from "../../broker.ts"; import { ClampRefused, makeKillSwitch, positionKeyOf, registerPaperVenue } from "../../broker.ts"; import type { OrderIntent } from "../../../engine/index.ts"; import type { NewBusEvent, OrderAction, Right } from "../../../bus/index.ts"; import type { IbkrConfig } from "./config.ts"; import { describeIbkrConfig } from "./config.ts"; import type { IbkrContract } from "./contract.ts"; import type { IbkrTransport } from "./transport.ts"; // ───────────────────────────────────────────────────────────────────────────── // Typed, fail-closed refusals. // ───────────────────────────────────────────────────────────────────────────── /** * Why the paper order face REFUSED to transmit (kestrel-7o2.8) — a closed, typed vocabulary. Each of * these means the SAME thing about the wire: **nothing was sent**. * * `naked-short` / `below-intrinsic` / `intrinsic-unknown` are the RISK DOCTRINE walls (never-naked; * a SELL floored at intrinsic; a floor never *assumed* satisfied). They are DISTINCT from the 7o2.9 * {@link ClampRefused} (a bounded-risk CEILING) because they are not ceilings at all — no budget * makes an uncovered short acceptable. */ export type IbkrOrderFailure = /** A `live` config reached the paper face. Live routing is 7o2.10, behind the human-signed arm. */ | "mode-gate" /** The intent itself is MALFORMED (qty ≤ 0 / fractional / non-finite; px ≤ 0 / non-finite; a ref * already on the ledger; a re-issued IB order id that would re-point correlation). Refused at wall 1 * — IB is NEVER this system's validator, and a SELL of `qty: -3` must not be allowed to sign-invert * its way through the risk wall. */ | "invalid-order" /** The leg has no pre-resolved gateway contract — an identity is never guessed on the hot path. */ | "unresolved-contract" /** The resulting position has an UNBOUNDED (non-computable / infinite) max_loss — a naked short CALL * or a short EQUITY. Refused by the BOUNDED-RISK platform INVARIANT itself, REGARDLESS of policy * (kestrel-buos): no budget can satisfy `size × max_loss ≤ budget` when max_loss is infinite. */ | "unbounded-risk" /** The resulting position's max_loss is FINITE but exceeds the risk budget (`size × max_loss > * budget`). Bounded risk is the invariant; a budget that cannot cover the worst case refuses. */ | "over-budget" /** The order would open (or deepen) an UNCOVERED SHORT whose max_loss is finite and budgeted (a * cash-secured / naked PUT) while the no-uncovered-short POLICY is ON (the default — the 0DTE book * keeps it hard). An OVERRIDABLE policy, not the platform invariant (kestrel-buos): turn it off to * sell puts. The UNBOUNDED cases above are refused regardless of it. */ | "naked-short" /** A SELL priced BELOW the leg's intrinsic value. */ | "below-intrinsic" /** A SELL whose intrinsic is UNKNOWN — the floor cannot be proven, so it fails closed. Applies to * OPTIONS only: an equity has no optionality and its floor is 0 (kestrel-buos §e). */ | "intrinsic-unknown" /** No OBSERVED two-sided book for the leg (unquoted, dark, one-sided, crossed), or no `@fair` at * all. A price we cannot audit against the venue's own book is not authorizable. */ | "anchor-unresolvable" /** The book on offer is DELAYED/FROZEN. That is a HEALTH SIGNAL, never a PRICE ANCHOR (7o2.8). */ | "anchor-stale" /** The `@fair` RECEIPT cannot vouch for the price — a stale index, frozen input, or no ATM coverage * (kestrel-ltrf). `@fair` is UNDERLYING-anchored, not book-anchored, so it may LEGITIMATELY sit * away from a lagging/wide index-option quote; the wall gates on an UNTRUSTED receipt, NOT on a naive * fair-outside-[bid,ask] clamp (which defeats the whole point of a fresh-index fair). */ | "fair-untrusted" /** The venue's `placeOrder` threw — surfaced LOUD, never swallowed. */ | "transmit-failed"; /** * The paper order face refused to transmit (kestrel-7o2.8) — a LOUD, typed, fail-closed refusal that * routes to STAND_DOWN. A DISTINCT class (not a bare `Error`, not {@link ClampRefused}) so a caller * can catch a DOCTRINE refusal specifically and can never mistake it for a bounded-risk ceiling. * NEVER carries a credential/account (the config is described through the redacting formatter). */ export class IbkrOrderRefused extends Error { override readonly name = "IbkrOrderRefused"; readonly failure: IbkrOrderFailure; /** The refused order's engine ref, when the refusal belongs to one. */ readonly ref: string | undefined; constructor( failure: IbkrOrderFailure, reason: string, options: { ref?: string | undefined; cause?: unknown } = {}, ) { super( `IBKR paper order REFUSED [${failure}]: ${reason} (fail-closed; NOTHING transmitted; STAND_DOWN)`, options.cause === undefined ? undefined : { cause: options.cause }, ); this.failure = failure; this.ref = options.ref; } } // ───────────────────────────────────────────────────────────────────────────── // Injected seams. // ───────────────────────────────────────────────────────────────────────────── /** A listener over the IB event bus (the transport's convention — concrete closures are wrapped at * the registration site, so the `never[]` never escapes into a handler body). */ type IbListener = (...args: never[]) => void; /** * The NARROW ORDER surface of the shared IB client. This is the ONE place in the package that names * `placeOrder`, and it exists only behind the guard chain above. * * Note the deliberate asymmetry with 7o2.5/7o2.6: the transport's {@link import("./transport.ts").IbClient} * and the contract layer's `IbContractClient` EXCLUDE every order method, so neither of them can * transmit even by reaching through the client. THIS view widens toward exactly two order calls (plus * the read-only position pull), and it is reached only via {@link orderClientOf} — a single, named, * auditable door. A test injects a double implementing exactly this; production passes the * transport's ONE shared client, so the feed face and the order face are two faces of ONE socket, * never a second connection. */ export interface IbOrderClient { /** Transmit an order. The `id` is the API client's order id (see {@link IbkrBrokerDeps.nextOrderId}). */ placeOrder(id: number, contract: Contract, order: Order): unknown; /** Pull a resting order by its IB order id. */ cancelOrder(orderId: number): unknown; /** Solicit the next valid order id (`nextValidId`) — the id sequence's origin. */ reqIds(numIds?: number): unknown; /** Subscribe to the account's AUTHORITATIVE position push (`position` / `positionEnd`). READ-ONLY. */ reqPositions(): unknown; on(event: EventName, listener: IbListener): unknown; removeListener(event: EventName, listener: IbListener): unknown; } /** * The ONE shared IB session, viewed through the ORDER surface (kestrel-7o2.8). The transport hands * out its single guarded client — throwing the typed connection error if the session is not connected * or has gone degraded, so an order can never be transmitted over a dead socket. This re-views it as * an {@link IbOrderClient}. The cast is the SINGLE, NAMED door through which order authority reaches * the socket; the real `IBApi` is a structural superset. There is never a second socket. */ export function orderClientOf(transport: IbkrTransport): IbOrderClient { return transport.client() as unknown as IbOrderClient; } /** * The pre-resolved contract book — a SYNCHRONOUS leg → contract lookup. * * Why it exists: the {@link Gate} seam is synchronous (`submit(intent): string`), but the 7o2.6 * resolution is a network round-trip. Resolving on the hot path would put a socket between the tick * and the order — so contracts are resolved AHEAD of it (at arm time) and looked up here. That is the * fire-then-inform discipline applied to identity: nothing slow, and nothing guessed, at the tick. * A leg that is not in the book is REFUSED (`unresolved-contract`), never resolved by hand. */ export interface ContractBook { get(leg: { readonly instrument: string; readonly strike?: number; readonly right?: Right }): IbkrContract | undefined; } /** Build a {@link ContractBook} from contracts already resolved through 7o2.6's `resolveContract`. * Keyed by {@link positionKeyOf}, so the book, the never-naked wall, the L0 position ceiling and the * reconciliation ledger all key IDENTICALLY (no skew). */ export function contractBook(contracts: readonly IbkrContract[]): ContractBook { const byKey = new Map(); for (const c of contracts) { byKey.set( c.kind === "option" ? positionKeyOf({ instrument: c.symbol, strike: c.strike, right: c.right }) : positionKeyOf({ instrument: c.symbol }), c, ); } return { get: (leg) => byKey.get(positionKeyOf(leg)) }; } // ───────────────────────────────────────────────────────────────────────────── // The PRICE ANCHOR (kestrel-7o2.8, the blocker-4 wall). // ───────────────────────────────────────────────────────────────────────────── /** * How the VENUE said it sourced a quote (IB's `marketDataType`: 1 live · 2 frozen · 3 delayed · * 4 delayed-frozen). **Only `live` may ANCHOR a price.** Delayed/frozen data is an honest HEALTH * SIGNAL — "is this thing tradeable?" — and it is never a price and never a value (RUNTIME §4). A * quote whose provenance we cannot establish is `unknown`, which is likewise refused: fail-closed. */ export type QuoteFreshness = "live" | "frozen" | "delayed" | "delayed-frozen" | "unknown"; /** * The PRICE ANCHOR for one leg (kestrel-7o2.8): the venue's OBSERVED two-sided book, and the `@fair` * the engine anchored `intent.px` on — the two numbers whose disagreement the live proof exposed. * * The 2026-07-14 dry run printed a ticket carrying `@fair = 0.9229` against an observed offer of * `0.73` — 28% ABOVE the ask on a liquid ATM 0DTE leg — and every wall still printed CLEARED, because * the walls bound SIZE, not PRICE. "A BUY never bids above fair" did zero work because FAIR ITSELF was * above the offer. So the book is no longer merely printed beside the price: it BOUNDS it, at the one * seam every order path crosses ({@link IbkrBroker.preflight}). */ export interface PriceAnchor { /** The venue's observed BEST BID. */ readonly bid: number; /** The venue's observed BEST OFFER. */ readonly ask: number; /** The `@fair` the ENGINE resolved for this leg — the anchor `intent.px` was derived from. It is * UNDERLYING-anchored (the fresh index spot), NOT book-anchored: for a lagging/wide index-option * quote it may LEGITIMATELY sit away from the posted `[bid, ask]`, so it is NOT clamped to the book * (kestrel-ltrf). Non-finite ⇒ UNKNOWN ⇒ refused (never assumed — a silent default is never a * default here). */ readonly fair: number; /** Whether the `@fair` RECEIPT vouches for the price (kestrel-ltrf). The ENGINE owns `@fair`'s * derivation and hands the adapter its verdict: `true` iff the receipt is live-anchored, ATM-covered, * and not stale/tainted. `false` ⇒ the anchor is UNTRUSTED ⇒ refused (`fair-untrusted`). This is * what REPLACES the round-1 naive fair-outside-[bid,ask] clamp — the wall gates on trust, not on * where fair happens to sit relative to a book that legitimately lags a fast index. */ readonly fairTrusted: boolean; /** The provenance of `bid`/`ask`. Anything but `live` refuses: a price anchor never accepts * delayed/frozen input. */ readonly freshness: QuoteFreshness; } /** Construction deps for {@link ibkrBroker}. Every non-determinism source (the socket, the order-id * minter) is injected, so the unit tests run with none of them. */ export interface IbkrBrokerDeps { /** The shared IB client's ORDER view — the transport's ONE session in production ({@link orderClientOf}). */ readonly client: IbOrderClient; /** Contracts resolved AHEAD of the hot path (7o2.6). A leg absent from it is refused. */ readonly contracts: ContractBook; /** Surface this adapter's fresh ORDER events onto the emitted bus — the SimGate `drain()` analogue. * Called after every `submit`/`cancel` AND after every inbound pump batch. */ readonly drain: () => void; /** The L0 pre-transmit ceilings (7o2.9). Over ANY of them ⇒ {@link ClampRefused}, nothing transmitted. */ readonly limits: RiskLimits; /** * The BOUNDED-RISK budget in dollars (kestrel-buos): a risk-INCREASING order is refused unless * `size × max_loss ≤ budget`. Absent ⇒ {@link RiskLimits.maxNotionalUsd} (a long's max_loss IS its * notional, so the two coincide there; the budget is what bounds a naked PUT, whose max_loss is * `(strike − premium) × multiplier` rather than the notional). Bounded risk is the PLATFORM * INVARIANT — an UNBOUNDED max_loss is refused at any budget. */ readonly budgetUsd?: number; /** * The no-uncovered-short POLICY (kestrel-buos) — an OVERRIDABLE default, ON when absent. When ON (the * 0DTE prop book keeps it hard) an order that would open UNCOVERED short exposure is refused even * when its max_loss is finite and budgeted (i.e. the naked PUT is refused). When OFF, a budgeted * naked put is ALLOWED. The UNBOUNDED cases (naked call / short equity) are refused REGARDLESS — they * fail the platform invariant, not merely the policy. A default is not an invariant. */ readonly noUncoveredShort?: boolean; /** The kill-switch to consult/trip. Absent ⇒ a fresh one ({@link IbkrBroker.killSwitch}). Share it * so an operator halt, a transport degradation, and a reconciliation break hit the SAME switch. */ readonly killSwitch?: KillSwitch; /** * The leg's per-contract INTRINSIC value in dollars-per-point (the engine computes it from * canonical state — this adapter never derives a price). `undefined` ⇒ UNKNOWN, and a SELL then * fails closed rather than assuming its floor is satisfied. */ readonly intrinsicOf: (leg: { readonly instrument: string; readonly strike?: number; readonly right?: Right; }) => number | undefined; /** * The leg's PRICE ANCHOR: the venue's OBSERVED two-sided book plus the `@fair` the engine priced * against (kestrel-7o2.8). REQUIRED — every order path crosses this wall, and there is no opt-out: * `undefined` (no observed book) REFUSES the order rather than transmitting against a price nothing * corroborates. Delayed/frozen input refuses too; a fair outside the book refuses outright. */ readonly priceAnchorOf: (leg: { readonly instrument: string; readonly strike?: number; readonly right?: Right; }) => PriceAnchor | undefined; /** The engine/Blotter EXPECTED signed net positions — the side {@link IbkrBroker.reconcile} compares * AGAINST the broker's authoritative truth. Never used as a risk input (broker truth is). */ readonly expectedPositions: () => PositionSnapshot; /** * The SETTLEMENT ORACLE (kestrel-7o2.24, ADR-0034 q3): the VENUE's own statement that this key's * expired position was cash-settled — position removed, cash posted. It is the ONLY thing that can * absolve a position's disappearance in {@link IbkrBroker.reconcile}, and it is supplied by the * VENUE composition (which reads the account's cash/expiry reports), never by this face. * * ABSENT ⇒ NOTHING IS EVER ABSOLVED — every disappearance trips, exactly as it did before this dep * existed. That is the fail-closed default and the reason the carve-out cannot leak into a caller * that did not ask for it. */ readonly settlementOf?: (key: PositionKey) => SettlementReceipt | undefined; /** Mint the next IB API order id. A COUNTER seeded from the gateway's `nextValidId` — never an RNG, * even here at the edge. */ readonly nextOrderId: () => number; /** The venue's positions as ALREADY KNOWN at construction (a resumed session's opening book). The * broker's `position` push and its own executions fold on top. Absent ⇒ flat. */ readonly seedPositions?: PositionSnapshot; /** Reconciliation tolerance (absolute qty per key). Absent ⇒ `0` (exact match required). */ readonly tolerance?: number; /** Time-in-force for transmitted orders. Defaults to `DAY` — an order that outlives the session is * an order nobody is watching. */ readonly tif?: TimeInForce; /** Redacted-diagnostic sink (default no-op). Receives only already-redacted strings. */ readonly log?: (line: string) => void; } // ───────────────────────────────────────────────────────────────────────────── // The ledger + the ticket. // ───────────────────────────────────────────────────────────────────────────── /** Where one order stands. `working` is the venue's acknowledgment; the three terminals are latched * (a terminal order never re-emits, so a replayed callback cannot perturb the stream). */ export type OrderPhase = "pending" | "working" | "filled" | "cancelled" | "rejected"; /** * One order's line in the RECONCILIATION LEDGER (kestrel-7o2.8): what the ENGINE submitted beside * what the BROKER actually reported. The broker's side is AUTHORITATIVE — it is never overwritten by * an engine expectation, and a divergence between the two is a break, not a correction. */ export interface IbkrOrderRecord { /** The engine's ref (`intent.ref`) — the correlation key on every ORDER event. */ readonly ref: string; /** The IB API order id this ref was transmitted under. */ readonly ibOrderId: number; /** The resolved intent, verbatim — the ENGINE's side of the ledger (including its untouched `px`). */ readonly intent: OrderIntent; /** The gateway's own contract definition this was transmitted against. */ readonly contract: IbkrContract; /** ENGINE truth: what we asked for. */ readonly submittedQty: number; /** BROKER truth: cumulative qty the venue reported EXECUTING (summed over distinct `execId`s). */ readonly filledQty: number; /** BROKER truth: size-weighted average of the prices the venue actually printed. */ readonly avgFillPx: number | undefined; /** BROKER truth: commission the venue reported (per `execId`, deduped). */ readonly commissionUsd: number; /** BROKER truth: the venue's own last-reported remaining quantity. */ readonly remainingQty: number | undefined; readonly phase: OrderPhase; } /** * A fully-guarded, NOT-YET-TRANSMITTED order (kestrel-7o2.8) — what {@link IbkrBroker.preflight} * returns and what the DRY-RUN prints for a human to review. It has cleared all four walls; it simply * has not been sent. Every number on it is READ, never invented: `limitPx` is the engine's own `px` * verbatim, `multiplier` is the VENUE's. */ export interface OrderTicket { readonly ref: string; /** The gateway's own resolved contract (7o2.6) — conId, OCC localSymbol, multiplier, expiry. */ readonly contract: IbkrContract; /** The exact IB `Contract` that would go on the wire. */ readonly ibContract: Contract; /** The exact IB `Order` that would go on the wire, minus the id (minted at transmit). */ readonly ibOrder: Order; readonly side: "buy" | "sell"; readonly qty: number; /** The ENGINE's price, verbatim. Never rounded, never snapped, never a mid. */ readonly limitPx: number; /** The VENUE's contract multiplier (dollars per point). Read from the definition, never assumed. */ readonly multiplier: number; /** `limitPx x qty x multiplier` — what the L0 notional ceiling bounds. */ readonly notionalUsd: number; /** * The DEFINED-RISK max loss in dollars. For a BUY of a long option this IS the premium (the whole * never-naked claim: you cannot lose more than you paid). For a BUY of equity it is bounded by the * price paid. A SELL that closes a held long is RISK-REDUCING — its max loss is already borne, so * it adds `0`. */ readonly maxLossUsd: number; /** The venue's signed net position in this leg BEFORE the order (broker truth). */ readonly heldBefore: number; /** Quantity of this leg ALREADY PROMISED AWAY by SELLs that are working (transmitted, acknowledged, * NOT yet filled). A resting sell does not move broker truth until it fills, so it is RESERVED here * — otherwise a second sell of the same long reads the same `heldBefore` and clears too, and both * fill (kestrel-7o2.8 blocker 1: the uncovered short this module actually opened). */ readonly workingSellQty: number; /** The WORST-CASE signed net position: `heldBefore - workingSellQty + signed(qty)` — what we hold * once this order AND every already-working SELL has filled. Never negative — never-naked. */ readonly heldAfter: number; /** The intrinsic floor that applied, when one did (a SELL). */ readonly intrinsicFloor: number | undefined; /** The PRICE ANCHOR this ticket cleared: the observed LIVE two-sided book, and the `@fair` inside * it. A ticket can only exist if its anchor was trustworthy, so this is a RECEIPT, not a hope. */ readonly anchor: PriceAnchor; /** The L0 verdict. `preflight` only ever RETURNS on `cleared` — anything else THREW. */ readonly clamp: "cleared"; /** The engine's price-resolution receipt, carried onto the ticket so `@fair` is auditable. */ readonly sourceAnnotation: string; } // ───────────────────────────────────────────────────────────────────────────── // SETTLEMENT — the one disappearance that is an EVENT, not a break (kestrel-7o2.24, ADR-0034 q3). // ───────────────────────────────────────────────────────────────────────────── /** * The VENUE's own statement that a specific EXPIRED position was CASH-SETTLED — the position removed, * the cash posted. This is the ONE piece of evidence that can absolve a position's disappearance from * {@link IbkrBroker.reconcile}'s trip. * * It is READ FROM THE VENUE, never derived here. The adapter cannot compute a settlement (it has no * settlement price, no account cash, no clearing calendar) and it must never assume one — a position * that "should have" settled is not a position that DID. So this arrives through the injected * {@link IbkrBrokerDeps.settlementOf} oracle, whose absence means NO disappearance is ever absolved. */ export interface SettlementReceipt { /** The SIGNED net quantity the venue settled away. Must account for the ENTIRE expected position — * a receipt that explains only part of a disappearance explains none of it. */ readonly qty: number; /** The settlement cash the venue posted, in dollars. `0` is LEGITIMATE (an option that expired * worthless — the common 0DTE end); the RECEIPT's existence is the evidence, not its size. A long * can only ever RECEIVE at settlement, so a negative value is refused. */ readonly cashUsd: number; /** When the venue settled it (the venue's own timestamp, ms). Must not predate the leg's expiry. */ readonly settledAt: number; } /** * One recorded SETTLEMENT — {@link IbkrBroker.reconcile}'s answer to a legitimately-expired position * that the venue removed and paid out. **This is a RECORD, not a correction**: the position is not * "reconciled away" silently; the disappearance is accounted for, on the ledger, with the receipt and * the expiry that justified it. * * It is deliberately NOT a Bus ORDER event. The {@link OrderAction} vocabulary is CLOSED * (`place | fill | cancel | reject`, bus/types.ts) — a settlement is not an order action, exactly as a * `commissionReport` is not (which likewise folds onto the LEDGER only). Promoting settlement to a * first-class Bus event would change `serialize(project(bus))` bytes repo-wide and needs its own ADR; * see the ADR-0034 q3 amendment. */ export interface SettlementRecord { readonly key: PositionKey; /** The engine-expected quantity that settled away (the receipt accounted for exactly this). */ readonly qty: number; /** The venue's posted settlement cash. */ readonly cashUsd: number; /** The leg's own expiry (`YYYYMMDD`), read from the venue's contract definition — never inferred. */ readonly expiry: string; /** The venue's settlement timestamp, verbatim from the receipt. */ readonly settledAt: number; /** The `now` the DRIVER had pinned when the settlement was recorded (RUNTIME §0 — never a wall clock). */ readonly observedAt: number; } /** * The leg's own EXPIRY INSTANT: 00:00 UTC on its `YYYYMMDD` expiry date. A PURE function of the string * (`Date.UTC` is arithmetic, not a clock) — nothing here reads the wall clock. * * A malformed or impossible date (`20260231`) yields `undefined`, which fails the carve-out closed: * an expiry we cannot establish is not an expiry. * * SPIKE-GRADE, and stated rather than smuggled (ADR-0034 q3): this is the START of expiry day, not the * moment the contract actually stops trading. It is therefore the LOOSER of the two available bounds — * it admits a disappearance at 09:00 on expiry day. It is safe only because it is a NECESSARY condition * beside a SUFFICIENT one: the venue's own settlement receipt is what actually does the absolving, and * nothing is absolved without it. The tighter bound (the leg's real last-trade/settlement instant) needs * a settlement-time field the contract layer does not carry yet (kestrel-7o2.24). */ function expiryInstantUtc(expiry: string): number | undefined { if (!/^\d{8}$/.test(expiry)) return undefined; const y = Number(expiry.slice(0, 4)); const m = Number(expiry.slice(4, 6)); const d = Number(expiry.slice(6, 8)); const ms = Date.UTC(y, m - 1, d); const back = new Date(ms); // Reject a date JS silently rolled over (20260231 → 2026-03-03): a wrong expiry is worse than none. if (back.getUTCFullYear() !== y || back.getUTCMonth() !== m - 1 || back.getUTCDate() !== d) return undefined; return ms; } // ───────────────────────────────────────────────────────────────────────────── // The paper order face. // ───────────────────────────────────────────────────────────────────────────── /** The IBKR PAPER {@link BrokerAdapter} (kestrel-7o2.8) — a Gate, plus the ledger/reconciliation * surface the safety envelope reads. */ export interface IbkrBroker extends BrokerAdapter { now: number; submit(intent: OrderIntent): string; cancel(ref: string): void; readonly events: readonly NewBusEvent[]; /** The BROKER's AUTHORITATIVE signed net position per {@link PositionKey} (ADR-0034 §4). */ positions(): PositionSnapshot; /** Guard an intent through all four walls and return its {@link OrderTicket} — TRANSMITTING NOTHING. */ preflight(intent: OrderIntent): OrderTicket; /** The per-order reconciliation ledger (engine expectation beside broker truth). */ ledger(): readonly IbkrOrderRecord[]; /** Compare the engine/Blotter EXPECTED positions + the ledger against the BROKER's own truth; any * break TRIPS the kill-switch, fail-closed. The ONE exception is a legitimately EXPIRED-AND-SETTLED * position, which is recorded on {@link IbkrBroker.settlements} instead (kestrel-7o2.24). */ reconcile(): void; /** The SETTLEMENT LEDGER: every disappearance {@link IbkrBroker.reconcile} accounted for as a * cash settlement rather than a break, with the receipt and expiry that justified each one. */ settlements(): readonly SettlementRecord[]; /** The kill-switch every transmit consults. Trip it to halt all transmission. On the `makeGate("paper")` * path this is the SAME instance the seam's {@link import("../../broker.ts").LiveClamp} consults — * `makeGate` threads it in, so a trip in EITHER layer (an operator STAND_DOWN on the clamp, or this * face's own at-observation never-naked trip) halts the whole path. There is exactly ONE. */ readonly killSwitch: KillSwitch; /** This face's configured L0 ceilings (kestrel-7o2.9). EXPOSED so `makeGate("paper")` can thread them * into the seam's {@link import("../../broker.ts").makeLiveClamp} — the SAME limits then back BOTH the * seam's L0 clamp and this face's WALL 5 (ADR-0034 §4/§7: L0 is above the adapter): TWO INDEPENDENTLY- * PROVEN ceilings over ONE set of numbers, so they can never diverge. NEITHER is subordinate — each is * mutation-proven load-bearing on its own. */ readonly limits: RiskLimits; /** The venue's TRUE per-intent contract multiplier — an option `100`, an equity `1` (kestrel-7o2.8). * EXPOSED so `makeGate("paper")` threads it into the seam's {@link import("../../broker.ts").makeLiveClamp} * L0 clamp, whose notional ceiling is then computed on REAL per-contract notional rather than a flat * `1×` that is 100× too loose for a 100× option. Resolves the contract from this face's ContractBook; * an UNRESOLVED contract returns the CONSERVATIVE `100` (over-estimate the notional so the seam ceiling * fails closed — the adapter's own WALL 2 then refuses the unresolved leg outright). Never transmits. */ multiplierOf(intent: OrderIntent): number; /** Detach every IB listener this face registered (idempotent; safe from a STAND_DOWN path). */ detach(): void; } /** A float epsilon for the intrinsic floor — a SELL exactly AT intrinsic must clear it, and binary * floating point must not turn `3.4 >= 3.4` into a refusal. Deliberately far below one tick. */ const PX_EPSILON = 1e-9; /** The conservative per-contract multiplier {@link IbkrBroker.multiplierOf} returns for an UNRESOLVED * contract (kestrel-7o2.8): the OPTION multiplier `100`, the larger of the two classes, so the seam * clamp OVER-estimates an unknown leg's notional and errs toward refusal (fail-closed). The adapter's * own WALL 2 then refuses the unresolved leg outright — this value only shapes the seam's ceiling math. */ const CONSERVATIVE_MULTIPLIER = 100; /** IB error code 202 — "Order Canceled - reason: …". A routine CANCEL, NOT a reject: mapping it onto * reject makes the Blotter learn the wrong terminal (kestrel-7o2.8 minor). */ const IB_ORDER_CANCELLED = 202; /** IB cancel-rejection codes — the venue REFUSED a cancel because the order is STILL LIVE: * 10148 "OrderId … cannot be cancelled, state: …" (live in some non-cancellable state) * 10147 "OrderId … that needs to be cancelled is not found" (cancel of an order we still track) * These are NOT order rejects. Terminating/releasing on one frees a reservation for a LIVE order — the * exact Route A naked short. An error of still-live/unknown fate must never free a reservation. */ const IB_CANCEL_REJECTION_CODES = new Set([10147, 10148]); /** * Is this order-scoped error a CANCEL-REJECTION — i.e. the order is STILL LIVE at the venue (Route A)? * Keyed on the documented cancel-rejection codes, with a message backstop for the "cannot be cancelled * / state:" family in case the venue reports a variant code. Fail-closed: when in doubt about a cancel * rejection, treat the order as live and DO NOT release its reservation. */ function isStillLiveOrderError(code: number, err: Error): boolean { if (IB_CANCEL_REJECTION_CODES.has(code)) return true; return /cannot be cancel|still live|, ?state:/i.test(err.message); } class IbkrPaperBroker implements IbkrBroker { now = 0; readonly #cfg: IbkrConfig; readonly #client: IbOrderClient; readonly #contracts: ContractBook; readonly #drain: () => void; readonly #limits: RiskLimits; readonly #budgetUsd: number; readonly #noUncoveredShort: boolean; readonly #intrinsicOf: IbkrBrokerDeps["intrinsicOf"]; readonly #priceAnchorOf: IbkrBrokerDeps["priceAnchorOf"]; readonly #expectedPositions: () => PositionSnapshot; readonly #settlementOf: ((key: PositionKey) => SettlementReceipt | undefined) | undefined; readonly #nextOrderId: () => number; readonly #tolerance: number; readonly #tif: TimeInForce; readonly #log: (line: string) => void; readonly killSwitch: KillSwitch; readonly #events: NewOrderEvent[] = []; /** ref → the ledger line. */ readonly #byRef = new Map(); /** IB order id → ref (the inbound pump's correlation). */ readonly #byIbId = new Map(); /** Executions already folded, by `execId` — IB replays them, and a fill must never double-count. */ readonly #seenExecs = new Set(); /** Commissions already folded, by `execId` (same replay hazard). */ readonly #seenCommissions = new Set(); /** BROKER truth, folded from the venue's OWN executions (`execDetails`) SINCE that key's baseline. */ readonly #execPositions = new Map(); /** * BROKER truth, from the venue's OWN account push (`position`) — the account's statement of record. * It is a BASELINE, not an answer: {@link IbkrPaperBroker.positions} adds the executions observed * SINCE it, and the push REBASES that fold (stamping it to zero) rather than overwriting it. * Overwriting is what shipped, and it silently DISCARDED every fill observed on top of a push — the * naked short of kestrel-7o2.8 blocker 2. */ readonly #venueBaseline = new Map(); /** * Quantity RESERVED per key by SELLs that are WORKING — transmitted, not yet filled. Broker truth * does not move until a fill prints, so without this a second sell of the same long reads the same * `heldBefore`, clears never-naked, and both fill. Reserved at SUBMIT; released on EVERY terminal * outcome (fill, cancel, reject) and on a transmit rollback. This is the never-naked wall's memory. */ readonly #workingSellQty = new Map(); /** The opening book a resumed session was seeded with. */ readonly #seed: PositionSnapshot; /** Disappearances accounted for as CASH SETTLEMENTS (kestrel-7o2.24) — one per key, at most. */ readonly #settlements = new Map(); #registered: Array<[EventName, IbListener]> = []; constructor(cfg: IbkrConfig, deps: IbkrBrokerDeps) { // WALL: PAPER ONLY. `live` is a Kestrel mode gate, not a port fact — live routing (7o2.10) lands // only behind the human-signed LiveArm, and this face ships none. Refused at CONSTRUCTION, so a // live-configured order face cannot even be built, let alone reached. if (cfg.mode === "live") { throw new IbkrOrderRefused( "mode-gate", `the IBKR order face is PAPER-ONLY (${describeIbkrConfig(cfg)}) — live routing is kestrel-7o2.10 and requires the human-signed LiveArm (protocol excludes \`live\` from WALLET_SIGNABLE_SCOPES); a config flag can never grant it`, ); } this.#cfg = cfg; this.#client = deps.client; this.#contracts = deps.contracts; this.#drain = deps.drain; this.#limits = deps.limits; // BOUNDED RISK: default the budget to the L0 notional ceiling (a long's max_loss IS its notional). this.#budgetUsd = deps.budgetUsd ?? deps.limits.maxNotionalUsd; // POLICY: no-uncovered-short defaults ON (the safe default; the 0DTE book keeps it hard). this.#noUncoveredShort = deps.noUncoveredShort ?? true; this.#intrinsicOf = deps.intrinsicOf; this.#priceAnchorOf = deps.priceAnchorOf; this.#expectedPositions = deps.expectedPositions; // ABSENT ⇒ the carve-out is structurally unreachable for this caller: no oracle, no absolution. this.#settlementOf = deps.settlementOf; this.#nextOrderId = deps.nextOrderId; this.#tolerance = deps.tolerance ?? 0; this.#tif = deps.tif ?? TimeInForce.DAY; this.#log = deps.log ?? ((): void => {}); this.killSwitch = deps.killSwitch ?? makeKillSwitch(); this.#seed = deps.seedPositions ?? {}; this.#attach(); // SUBSCRIBE to the venue's own statement of record — IB does NOT emit `position` unsolicited. // Without this call the venue map stays permanently EMPTY in production and `positions()` degrades // to the seed plus our own execution fold: the ENGINE's mirror, not the BROKER's truth, wearing // the label of broker truth (kestrel-7o2.8 blocker 3). An invariant is only real if something // calls it, so the pull is subscribed HERE, at construction, not left to a caller to remember. try { this.#client.reqPositions(); } catch (cause) { this.killSwitch.trip( `could not subscribe to the venue's POSITION push (reqPositions threw: ${String(cause)}) — the broker's own statement of record is unavailable, so every risk wall would be reading a guess. Halting paper transmission (fail-closed)`, ); } } // ── the Gate seam ─────────────────────────────────────────────────────────── get events(): readonly NewBusEvent[] { return this.#events; } /** This face's configured L0 ceilings — read by `makeGate("paper")` so the seam's L0 clamp enforces the * SAME limits. One of TWO INDEPENDENTLY-PROVEN ceilings, not a backstop to the other: each is load-bearing * on its own (a mutation gutting either turns a test RED). */ get limits(): RiskLimits { return this.#limits; } /** The venue's TRUE per-intent contract multiplier (kestrel-7o2.8) — resolved from this face's OWN * ContractBook (an option `100`, an equity `1`), the SAME number WALL 5's notional uses. `makeGate("paper")` * threads this into the seam clamp so its notional ceiling is computed on real per-contract notional, * not a flat `1×` that is 100× too loose for a 100× option. FAIL-CLOSED on an unresolved contract: it * returns the CONSERVATIVE {@link CONSERVATIVE_MULTIPLIER} (over-estimate the notional so the seam * ceiling errs toward refusal); the adapter's own WALL 2 then refuses the unresolved leg outright. * Transmits nothing — a read-only resolution. */ multiplierOf(intent: OrderIntent): number { return this.#contracts.get(intent)?.multiplier ?? CONSERVATIVE_MULTIPLIER; } /** * Guard a resolved intent through all four walls and return the {@link OrderTicket} it would * transmit — **TRANSMITTING NOTHING**. `submit` is exactly `preflight` + `placeOrder`, so the ticket * a human reviews in the DRY-RUN is bit-for-bit the order that would go on the wire. Throws the same * typed refusals `submit` does; it simply never reaches the socket. */ preflight(intent: OrderIntent): OrderTicket { // ── WALL 0: the kill-switch. First, so a halted process refuses uniformly (7o2.9). if (this.killSwitch.tripped) { throw new ClampRefused( "killed", intent.ref, `paper transmission halted — kill-switch tripped: ${this.killSwitch.reason ?? "(no reason)"} (fail-closed)`, ); } // ── WALL 1: THE INTENT ITSELF. A malformed order is refused HERE, not handed to IB to validate. // A SELL of `qty: -3` sign-inverts (`heldAfter` INCREASES), sails through every risk wall below, // and puts `totalQuantity: -3` on the wire. The walls may only reason about a well-formed order. if (!Number.isFinite(intent.qty) || !Number.isInteger(intent.qty) || intent.qty <= 0) { throw new IbkrOrderRefused( "invalid-order", `qty ${intent.qty} is not a positive whole number of contracts/shares — refusing at the boundary rather than letting IB be this system's validator (a negative qty would sign-invert straight through the never-naked wall)`, { ref: intent.ref }, ); } if (!Number.isFinite(intent.px) || intent.px <= 0) { throw new IbkrOrderRefused( "invalid-order", `px ${intent.px} is not a positive finite limit price — the adapter transmits the engine's number or it REFUSES; it never repairs one`, { ref: intent.ref }, ); } if (this.#byRef.has(intent.ref)) { throw new IbkrOrderRefused( "invalid-order", `ref ${intent.ref} is ALREADY on the ledger — a second order under a live ref would clobber its line (and orphan any reservation it holds). Refs are the engine's correlation key and are never reused`, { ref: intent.ref }, ); } // ── WALL 2: the contract. Pre-resolved by 7o2.6 or nothing — never guessed on the hot path. const contract = this.#contracts.get(intent); if (contract === undefined) { throw new IbkrOrderRefused( "unresolved-contract", `leg ${positionKeyOf(intent)} has no pre-resolved gateway contract — an identity is NEVER guessed at the tick (resolve it ahead of the hot path via resolveContract, kestrel-7o2.6)`, { ref: intent.ref }, ); } const key = positionKeyOf(intent); // The BROKER's own reported truth is the risk input — never the engine's expectation (ADR-0034 §4). const heldBefore = this.positions()[key] ?? 0; // …MINUS what our own WORKING SELLs have already promised away. A resting sell has not moved // broker truth yet, so truth alone over-states what is still ours to sell. const workingSellQty = this.#workingSellQty.get(key) ?? 0; const availableBefore = heldBefore - workingSellQty; const signed = intent.side === "buy" ? intent.qty : -intent.qty; const heldAfter = availableBefore + signed; const multiplier = contract.multiplier; const notionalUsd = intent.px * intent.qty * multiplier; // ── WALL 3: BOUNDED RISK + the (overridable) no-uncovered-short POLICY (kestrel-buos). // // The PLATFORM INVARIANT is BOUNDED RISK: max_loss must be COMPUTABLE (finite) and // `size × max_loss ≤ budget`, fail-closed. "Never naked" is an APPLICATION policy (default ON), not // a platform constraint — two reasonable strategies differ on a cash-secured put (ARCHITECTURE §6), // so the platform forbids only the ALWAYS-wrong cases (unbounded max_loss) and leaves the merely // risky ones to an overridable dial. // // A STRICTLY RISK-REDUCING order is PERMITTED BY CONSTRUCTION: a BUY against a short, or a SELL that // closes (does not exceed) a long. The face must ALWAYS be able to GET FLAT, so these are never // refused for DIRECTION — the crude `heldAfter < 0` sign test refused a BUY that reduces a short // (it could never buy its way flat) and a riskless equity close-out, and it bounded a SIGN, not a // RISK. A risk-reducing order still passes the L0 ceilings and the price-anchor wall below — // reducing risk is not a licence to transmit garbage — but it is never refused for direction. // // The exposure is measured against the WORST CASE — broker truth minus every working sell — so the // second sell of a long a first still-resting sell already spoke for is not treated as covered // (7o2.8 blocker 1's reservation). const isBuy = intent.side === "buy"; const riskReducing = (isBuy && availableBefore < 0) || (!isBuy && heldAfter >= 0); let maxLossUsd = 0; if (!riskReducing) { if (isBuy) { // Opening/adding a LONG. max_loss is the premium (option) / notional (equity) PAID — finite by // construction. The L0 notional ceiling (wall 5) bounds it against the budget below. maxLossUsd = notionalUsd; } else { // A SELL that leaves a NET SHORT of `shortQty`. Compute the RESULTING position's max_loss, or // refuse if it is unbounded — bounded risk is the invariant, enforced regardless of policy. const shortQty = -heldAfter; if (contract.kind === "equity") { throw new IbkrOrderRefused( "unbounded-risk", `a SHORT EQUITY at ${key} (net ${heldAfter}) has an UNBOUNDED max_loss (the stock can rise without limit) — bounded risk cannot be satisfied at ANY budget, so it is refused REGARDLESS of the no-uncovered-short policy (kestrel-buos: bounded risk is the invariant)`, { ref: intent.ref }, ); } if (contract.right === "C") { throw new IbkrOrderRefused( "unbounded-risk", `a naked short CALL at ${key} (net ${heldAfter}) has an UNBOUNDED max_loss — refused by the BOUNDED-RISK invariant itself, regardless of policy (kestrel-buos)`, { ref: intent.ref }, ); } // A short PUT: max_loss = (strike − premium) × multiplier per contract, the stock floored at 0. // FINITE — a cash-secured put is a defined-risk trade. `intent.px` is the premium the engine // priced; the adapter never invents it. const perContract = Math.max(contract.strike - intent.px, 0) * multiplier; maxLossUsd = perContract * shortQty; if (!Number.isFinite(maxLossUsd)) { throw new IbkrOrderRefused( "unbounded-risk", `max_loss for the short PUT at ${key} is not computable (non-finite) — fail-closed (kestrel-buos)`, { ref: intent.ref }, ); } // BUDGET: bounded risk requires size × max_loss ≤ budget. if (maxLossUsd > this.#budgetUsd + PX_EPSILON) { throw new IbkrOrderRefused( "over-budget", `the short PUT at ${key} (net ${heldAfter}) carries max_loss $${maxLossUsd} which exceeds the risk budget $${this.#budgetUsd} — bounded risk requires size × max_loss ≤ budget (kestrel-buos, fail-closed)`, { ref: intent.ref }, ); } // POLICY: no-uncovered-short (default ON — the 0DTE book keeps it hard). When ON, even a // budgeted, finite-risk uncovered put is refused. When OFF, it is ALLOWED. A dial, not the // invariant — the UNBOUNDED cases above are refused regardless. if (this.#noUncoveredShort) { throw new IbkrOrderRefused( "naked-short", `${intent.side} ${intent.qty} of ${key} would leave a NET SHORT of ${shortQty} (uncovered). Its max_loss $${maxLossUsd} is FINITE and within budget, but the no-uncovered-short POLICY is ON (the default — the 0DTE book keeps it hard). Refusing (kestrel-buos: turn the policy off to sell puts; bounded risk still binds)`, { ref: intent.ref }, ); } } } // ── WALL 4: the INTRINSIC FLOOR. A SELL (a close of a held long) may never price below intrinsic. // The adapter cannot re-price, so it cannot FLOOR the price — it REFUSES, and the engine's own // floor (engine/pricing.ts) remains the one place a price is made. // // The floor is keyed on INSTRUMENT KIND, not on undefined-ness (kestrel-buos §e): an OPTION has an // intrinsic and an UNKNOWN one refuses (never assumed satisfied); an EQUITY has NO optionality, so // its floor is simply 0 and an undefined intrinsic is not a refusal — the round-1 test that keyed // the floor on undefined-ness obstructed a RISKLESS equity close-out in live testing. let intrinsicFloor: number | undefined; if (intent.side === "sell" && contract.kind === "option") { const intr = this.#intrinsicOf(intent); if (intr === undefined || !Number.isFinite(intr)) { throw new IbkrOrderRefused( "intrinsic-unknown", `the intrinsic of ${key} is UNKNOWN, so the SELL floor cannot be proven — refusing rather than ASSUMING the floor is satisfied (a silent default is never a default here)`, { ref: intent.ref }, ); } intrinsicFloor = intr; if (intent.px + PX_EPSILON < intr) { throw new IbkrOrderRefused( "below-intrinsic", `SELL ${key} at ${intent.px} is BELOW its intrinsic ${intr} — a sell is floored at intrinsic, always. The adapter is a transmitter and will not re-price it up`, { ref: intent.ref }, ); } } // ── WALL 5: the L0 CLAMP (7o2.9 RiskLimits) — the ADAPTER's venue-boundary ceiling, one of TWO // independently-proven ceilings (defense-in-depth, ADR-0034 §4). // // The OTHER ceiling is the seam's makeLiveClamp ABOVE the adapter. On the `makeGate("paper")` path // that clamp wraps this face, SHARES this face's ONE kill-switch (threaded in by makeGate), and // enforces the SAME limits AND the SAME true per-contract multiplier (both threaded in via // `this.limits` / `this.multiplierOf`) — so an over-ceiling order is refused by the seam before it // reaches here. But this block is NOT a subordinate shadow of a single canonical owner: it is an // INDEPENDENT barrier proven load-bearing on its own. (a) This face is ALSO reachable BARE (unit // tests, the env-gated dry-run/equity-order proof scripts) where NO seam wraps it, and fail-closed // forbids a bare venue face with no ceiling — a mutation gutting this block goes RED there. (b) It // uses the VENUE's true per-contract `multiplier` (100 for an option, 1 for equity), the SAME number // the seam now reads via multiplierOf — same limits, same switch, never divergent. Two barriers. if (intent.qty > this.#limits.maxOrderQty) { throw new ClampRefused( "order-size", intent.ref, `order qty ${intent.qty} exceeds maxOrderQty ${this.#limits.maxOrderQty} (fail-closed, bounded-risk)`, ); } if (Math.abs(heldAfter) > this.#limits.maxPositionQty) { throw new ClampRefused( "position", intent.ref, `projected net position ${heldAfter} at ${key} exceeds maxPositionQty ${this.#limits.maxPositionQty} (fail-closed)`, ); } if (notionalUsd > this.#limits.maxNotionalUsd) { throw new ClampRefused( "notional", intent.ref, `order notional ${notionalUsd} (px ${intent.px} x qty ${intent.qty} x multiplier ${multiplier}) exceeds maxNotionalUsd ${this.#limits.maxNotionalUsd} (fail-closed)`, ); } // ── WALL 6: THE PRICE ANCHOR (kestrel-7o2.8 blocker 4; corrected per kestrel-ltrf). The // walls above bound SIZE; this one bounds the PRICE. It gates on whether the `@fair` RECEIPT can be // TRUSTED — NOT on a naive fair-outside-[bid,ask] clamp. `@fair` is UNDERLYING-anchored (the fresh // index spot), not book-anchored: for a fast-moving index option the posted quote lags the fast index // and/or is wide, so fair LEGITIMATELY sits away from the posted option quote — clamping to // [bid,ask] would defeat the whole point of fair. What stands, fail-closed: // // (a) NO OBSERVED BOOK, or a dark / one-sided / crossed one ⇒ the anchor is UNRESOLVABLE. // (b) DELAYED / FROZEN data ⇒ a HEALTH SIGNAL, never a price-anchor INPUT. Request live; without // it the anchor is UNRESOLVABLE (this part of the round-1 wall is kept verbatim). // (c) an UNKNOWN (non-finite) @fair ⇒ unresolvable. // (d) an UNTRUSTED @fair RECEIPT — a stale index, a frozen input, no ATM coverage — ⇒ refuse to // transmit on a fair the receipt cannot vouch for. The precise fresh-tight-book diagnostic // belongs to the fair redesign (src/fair, kestrel-ku99/ltrf — OUT OF SCOPE here); this module // consumes the engine's TRUST verdict rather than re-deriving it or clamping to a lagging book. const anchor = this.#priceAnchorOf(intent); if (anchor === undefined) { throw new IbkrOrderRefused( "anchor-unresolvable", `no OBSERVED two-sided book for ${key} — the PRICE ANCHOR is UNRESOLVABLE, and an unaudited price is never authorizable (fail-closed; the book is never assumed)`, { ref: intent.ref }, ); } if (anchor.freshness !== "live") { throw new IbkrOrderRefused( "anchor-stale", `the book for ${key} is ${anchor.freshness.toUpperCase()} (bid=${anchor.bid} ask=${anchor.ask}) — delayed/frozen data is a HEALTH SIGNAL, never a PRICE ANCHOR (RUNTIME §4). Anything that PRICES an order requires LIVE data; without it the anchor is UNRESOLVABLE`, { ref: intent.ref }, ); } if ( !Number.isFinite(anchor.bid) || !Number.isFinite(anchor.ask) || anchor.bid <= 0 || anchor.ask <= 0 || anchor.ask + PX_EPSILON < anchor.bid ) { throw new IbkrOrderRefused( "anchor-unresolvable", `the observed book for ${key} is DARK / ONE-SIDED / CROSSED (bid=${anchor.bid} ask=${anchor.ask}) — that is not a book, and a fabricated level is exactly what fail-closed forbids`, { ref: intent.ref }, ); } if (!Number.isFinite(anchor.fair)) { throw new IbkrOrderRefused( "anchor-unresolvable", `@fair for ${key} is UNKNOWN — the price cannot be corroborated, and it is never ASSUMED sound (a silent default is never a default here)`, { ref: intent.ref }, ); } if (!anchor.fairTrusted) { throw new IbkrOrderRefused( "fair-untrusted", `the @fair ${anchor.fair} for ${key} carries an UNTRUSTED receipt (a stale index, a frozen input, or no ATM coverage) — refusing to transmit on a fair the receipt cannot vouch for (kestrel-ltrf). @fair is UNDERLYING-anchored, so it may legitimately sit away from the posted book ${anchor.bid} / ${anchor.ask}; the wall gates on the receipt's TRUST, never on a naive book clamp`, { ref: intent.ref }, ); } // Every wall cleared. Build the EXACT wire payload — and hand it back untransmitted. const ibContract = ibContractOf(contract); const ibOrder: Order = { action: intent.side === "buy" ? IbOrderAction.BUY : IbOrderAction.SELL, orderType: OrderType.LMT, totalQuantity: intent.qty, // THE ENGINE'S PRICE, VERBATIM. Not rounded. Not snapped "to improve". Never a mid. lmtPrice: intent.px, tif: this.#tif, transmit: true, orderRef: intent.ref, ...(this.#cfg.account === undefined ? {} : { account: this.#cfg.account }), }; return { ref: intent.ref, contract, ibContract, ibOrder, side: intent.side, qty: intent.qty, limitPx: intent.px, multiplier, notionalUsd, // The DEFINED-RISK max_loss the resulting position carries (kestrel-buos): the premium/notional // for a long, `(strike − premium) × multiplier × shortQty` for a budgeted short put, and 0 for a // strictly risk-reducing order. A ticket only exists if this is FINITE and within budget. maxLossUsd, heldBefore, workingSellQty, heldAfter, intrinsicFloor, anchor, clamp: "cleared", sourceAnnotation: intent.sourceAnnotation, }; } /** * Transmit a resolved intent to the paper venue. `preflight` FIRST (all four walls; any refusal * throws and nothing reaches the wire), then exactly one `placeOrder` at the engine's own price. * Returns the ref the engine correlates the venue's later ORDER events against. */ submit(intent: OrderIntent): string { const ticket = this.preflight(intent); // throws ⇒ NOTHING transmitted const ibOrderId = this.#nextOrderId(); // MINOR: a re-issued IB order id must NOT silently re-point correlation. A duplicate `nextOrderId` // would map this ref's order id onto a ref already in flight, so an EARLIER order's executions would // fold under a LATER record — inflating the long the risk wall reads. Refuse it like a duplicate ref // (invalid-order); NOTHING is transmitted, and no reservation is taken. if (this.#byIbId.has(ibOrderId)) { throw new IbkrOrderRefused( "invalid-order", `nextOrderId re-issued ibOrderId ${ibOrderId}, already correlated to ref ${this.#byIbId.get(ibOrderId)} — a duplicated order id would re-point correlation and fold one order's executions under another (inflating the risk wall's read). Refusing (kestrel-7o2.8)`, { ref: intent.ref }, ); } const order: Order = { ...ticket.ibOrder, orderId: ibOrderId }; const rec: MutableRecord = { ref: intent.ref, ibOrderId, intent, contract: ticket.contract, submittedQty: intent.qty, filledQty: 0, fillNotional: 0, commissionUsd: 0, remainingQty: undefined, avgFillPx: undefined, phase: "pending", placed: false, terminal: false, reservedQty: 0, execIds: new Set(), }; this.#byRef.set(intent.ref, rec); this.#byIbId.set(ibOrderId, intent.ref); // RESERVE the working SELL, BEFORE the wire. From this instant the long it closes is spoken for: // no second sell of it can clear never-naked, however long this one rests unfilled. Released on // EVERY terminal outcome — and on the rollback below, so a dead socket never locks a long either. if (intent.side === "sell") { const key = positionKeyOf(intent); rec.reservedQty = intent.qty; this.#workingSellQty.set(key, (this.#workingSellQty.get(key) ?? 0) + intent.qty); } try { this.#client.placeOrder(ibOrderId, ticket.ibContract, order); } catch (cause) { // ROUTE C: a throw from the socket is LOUD, and the order's fate is UNKNOWN — a partial socket // write may have left it LIVE at the venue. A silent assume-NOT-filled is the exact mirror of the // forbidden silent assume-filled: if it fills, its execution must still find its record and fold // into broker truth, and its reservation must still guard the long. So we KEEP the correlation // (#byRef / #byIbId) and KEEP the reservation, marking the record UNKNOWN-FATE — never erased. A // second sell of the same long is then refused (the reservation stands), and reconciliation // resolves the order when the venue speaks. The throw below is the loud surface. rec.unknownFate = true; rec.phase = "pending"; this.#log( `placeOrder THREW for ${intent.ref} [ibOrderId=${ibOrderId}] — UNKNOWN FATE (may be LIVE at the venue). Keeping its correlation + reservation; it will fold if it fills (Route C, fail-closed): ${String(cause)}`, ); throw new IbkrOrderRefused( "transmit-failed", `placeOrder threw on the IB Gateway socket for ${intent.ref} (${describeIbkrConfig(this.#cfg)}) — the order's fate is UNKNOWN and its correlation + reservation are KEPT (never assume not-filled)`, { ref: intent.ref, cause }, ); } this.#log(`transmitted ${intent.side} ${intent.qty} ${positionKeyOf(intent)} @ ${intent.px} [ibOrderId=${ibOrderId}]`); // No ORDER event is minted here: `place` comes from the VENUE's own acknowledgment (the inbound // pump), because the broker's report is authoritative — not our optimism that it arrived. this.#drain(); return intent.ref; } /** Pull a resting order. A no-op on an unknown/already-terminal ref (fail-closed, never a crash). * The `cancel` ORDER event is minted by the VENUE's terminal status, not by our request. */ cancel(ref: string): void { const rec = this.#byRef.get(ref); if (rec === undefined || rec.terminal) { this.#drain(); return; } try { this.#client.cancelOrder(rec.ibOrderId); } catch (cause) { // A cancel is RISK-REDUCING: a failure to pull is loud in the log, but it must never crash the // STAND_DOWN path it is usually called from. this.#log(`cancelOrder threw for ${ref} [ibOrderId=${rec.ibOrderId}]: ${String(cause)}`); } this.#drain(); } // ── the broker's AUTHORITATIVE truth ──────────────────────────────────────── /** * The BROKER's own signed net position per {@link PositionKey} (ADR-0034 §4) — the risk input every * wall reads, and the reconciliation anchor. It is NEVER the engine's expectation. * * It is a BASELINE PLUS A FOLD, never a last-write-wins overwrite: * * - the account's own `position` push is the BASELINE for its key (its statement of record, which * outranks the seed — a resumed session's opening book); * - the venue's OWN executions observed SINCE that baseline fold on top. * * The shipped version let the push OVERWRITE the fold, so once IB had pushed a key, every fill this * adapter subsequently OBSERVED was silently dropped from the risk input — and a discarded fill is a * naked short waiting to happen (kestrel-7o2.8 blocker 2: push LONG 2 → sell 2 → the venue prints * the execution → `positions()` still said 2 → a second sell of 2 cleared and transmitted). */ positions(): PositionSnapshot { const snap: Record = {}; for (const [key, qty] of Object.entries(this.#seed)) snap[key] = qty; // The venue's statement of record REPLACES the seed for its key — and RE-BASES the fold, which // was stamped to zero at the push, so what follows counts only what we have observed SINCE. for (const [key, qty] of this.#venueBaseline) snap[key] = qty; for (const [key, qty] of this.#execPositions) snap[key] = (snap[key] ?? 0) + qty; return snap; } ledger(): readonly IbkrOrderRecord[] { return [...this.#byRef.values()].map( (r): IbkrOrderRecord => ({ ref: r.ref, ibOrderId: r.ibOrderId, intent: r.intent, contract: r.contract, submittedQty: r.submittedQty, filledQty: r.filledQty, avgFillPx: r.avgFillPx, commissionUsd: r.commissionUsd, remainingQty: r.remainingQty, phase: r.phase, }), ); } /** * The RECONCILIATION TRIP (kestrel-7o2.9 / ADR-0034 §4). Two comparisons, both anchored to the * BROKER's own report — which is authoritative, always: * * 1. **Per-order**: the venue may never report EXECUTING more than we submitted. An over-fill is a * break, not something the engine reconciles away. * 2. **Per-position**: the engine/Blotter EXPECTED snapshot vs the broker's PULL. A broker fill the * engine never originated, or an engine order with no broker terminal state, both diverge here. * * Any break beyond `tolerance` TRIPS the {@link KillSwitch} — every subsequent transmit then refuses * `killed`. Never a silent divergence. * * ## The ONE carve-out: EXPIRY / CASH SETTLEMENT (kestrel-7o2.24, ADR-0034 q3) * A cash-settled index option held to settle DISAPPEARS from the venue's position report and is * replaced by cash. Read as expected-N-vs-broker-0 that is a break — so the switch tripped on the * NORMAL END of every hold-to-cash-settle position, i.e. on the happy path of the whole design. * * So one shape, and only one, is recorded as a {@link SettlementRecord} instead of tripping. It is * narrow ON PURPOSE — a broad carve-out silently deletes the property this method exists for. See * {@link IbkrPaperBroker.settlementAbsolving} for the conditions; every one of them is necessary and * none is sufficient alone. */ reconcile(): void { for (const r of this.#byRef.values()) { if (r.filledQty > r.submittedQty + this.#tolerance) { this.killSwitch.trip( `reconciliation break (OVER-FILL) on ${r.ref} [ibOrderId=${r.ibOrderId}]: engine submitted ${r.submittedQty}, broker reported executing ${r.filledQty} — the BROKER's report is authoritative; halting paper transmission (ADR-0034 §4, fail-closed)`, ); return; } } const expected = this.#expectedPositions(); const actual = this.positions(); for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) { const e = expected[key] ?? 0; const a = actual[key] ?? 0; if (Math.abs(a - e) > this.#tolerance) { // The ONE disappearance that is an EVENT rather than a break: a long that reached its own // expiry and that the VENUE ITSELF says it settled for cash. Anything else falls through and // trips — including a vanish on expiry day with no receipt, and a receipt that does not // account for the whole position. const settled = this.#settlementAbsolving(key, e, a); if (settled !== undefined) { this.#recordSettlement(settled); continue; } this.killSwitch.trip( `reconciliation break at ${key}: engine expected ${e}, broker PULL reported ${a} (delta ${a - e}, tolerance ${this.#tolerance}) — halting paper transmission (ADR-0034 §4, fail-closed)`, ); return; // the first break latches the switch; every subsequent submit refuses "killed" } } } settlements(): readonly SettlementRecord[] { return [...this.#settlements.values()]; } /** * Is this per-key divergence a legitimate EXPIRY CASH SETTLEMENT (kestrel-7o2.24)? Returns the * {@link SettlementRecord} to file when it is, `undefined` when it is not — and `undefined` means the * caller TRIPS. Every condition below is NECESSARY; the venue's own receipt is the only one that is * anywhere near sufficient, and it still does not stand alone. * * The narrowness IS the safety property. A position vanishing for any other reason — an unexplained * broker adjustment, a position closed by someone else on the account, a leg that vanished before it * could possibly have expired, a partial disappearance — must still trip, because each of those is a * book we no longer agree with the venue about, and that is precisely what the kill-switch is for. */ #settlementAbsolving(key: PositionKey, expected: number, actual: number): SettlementRecord | undefined { // (1) NO ORACLE, NO ABSOLUTION. A caller that supplied no settlement source gets the pre-carve-out // behaviour exactly, with no branch it could accidentally fall into. if (this.#settlementOf === undefined) return undefined; // (2) The position must be GONE — not reduced. A partial disappearance is not a settlement; a // settled contract settles in full. if (Math.abs(actual) > this.#tolerance) return undefined; // (3) It must have been a LONG. The spike is BUY-only and hold-to-cash-settle; a SHORT that // vanishes is assignment/exercise territory, whose cash flows this receipt does not model. // Fail closed on it rather than guess. if (!(expected > this.#tolerance)) return undefined; // (4) The leg must be an OPTION WITH A KNOWN EXPIRY, taken from the VENUE's own pre-resolved // contract definition (WALL 2's book, via this session's ledger) — never inferred from a // symbol, a calendar, or the receipt itself. No definition ⇒ no expiry ⇒ no absolution. const contract = this.#contractForKey(key); if (contract === undefined || contract.kind !== "option") return undefined; const expiryAt = expiryInstantUtc(contract.expiry); if (expiryAt === undefined) return undefined; // (5) The SESSION must actually have REACHED the leg's own expiry, by the clock the DRIVER pinned // (RUNTIME §0 — `now` is tape time, never a wall clock). A position that vanished before its // expiry did not expire, whatever anyone reports about it. if (!Number.isFinite(this.now) || this.now < expiryAt) return undefined; // (6) THE VENUE'S OWN RECEIPT. The date is a necessary condition, never the evidence: a position // that merely went missing on expiry day is not a settled position. const receipt = this.#settlementOf(key); if (receipt === undefined) return undefined; // (7) The receipt must account for the ENTIRE expected position, exactly. A receipt for a // different size explains a different position, and an unexplained delta is a break. if (!Number.isFinite(receipt.qty) || Math.abs(receipt.qty - expected) > this.#tolerance) return undefined; // (8) The cash must be REAL and non-negative. `0` is legitimate (expired worthless — the common // 0DTE end); a long can only ever RECEIVE at settlement, so a negative is refused, and a // non-finite one is UNKNOWN, which is never a default. if (!Number.isFinite(receipt.cashUsd) || receipt.cashUsd < 0) return undefined; // (9) The settlement must not PREDATE the expiry it claims to settle. if (!Number.isFinite(receipt.settledAt) || receipt.settledAt < expiryAt) return undefined; return { key, qty: expected, cashUsd: receipt.cashUsd, expiry: contract.expiry, settledAt: receipt.settledAt, observedAt: this.now, }; } /** * The VENUE's own contract definition for a position key, taken from THIS session's ledger (every * line carries the pre-resolved {@link IbkrContract} it was transmitted against — WALL 2 guarantees * it). A key with no ledger line has no definition here, so it can never be absolved. * * KNOWN NARROWNESS, stated rather than smuggled: a position this session did not ORIGINATE — a * resumed session's seeded book, or a leg pushed by the account and never traded here — has no * ledger line, so its expiry settlement will TRIP rather than record. That errs toward the halt, * which is the right direction, and closing it needs a key→contract lookup the {@link ContractBook} * does not expose today (kestrel-7o2.24). */ #contractForKey(key: PositionKey): IbkrContract | undefined { for (const r of this.#byRef.values()) { if (positionKeyOf(r.intent) === key) return r.contract; } return undefined; } /** File the settlement — once per key — and say so out loud. A carve-out that absolves WITHOUT * recording is an erased position, which is the very thing `reconcile()` exists to make impossible. */ #recordSettlement(rec: SettlementRecord): void { if (this.#settlements.has(rec.key)) return; this.#settlements.set(rec.key, rec); this.#log( `SETTLEMENT at ${rec.key}: the engine expected ${rec.qty}, the venue reports 0 — the leg reached its own expiry (${rec.expiry}) and the venue posted $${rec.cashUsd} of settlement cash at ${rec.settledAt}. Recorded as a settlement, NOT a reconciliation break (kestrel-7o2.24, ADR-0034 q3)`, ); } // ── the inbound pump ──────────────────────────────────────────────────────── /** Wire the venue's own callbacks onto the CLOSED ORDER vocabulary. Registered once at construction; * {@link detach} removes exactly these (never the blanket `removeAllListeners` hammer — the socket * is SHARED with the feed face). */ #attach(): void { this.#on(EventName.openOrder, (orderId: number, _c: Contract, _o: Order, _s: OrderState) => { // The venue ACKNOWLEDGES the order exists. That — not our `placeOrder` returning — is `place`. const rec = this.#byIbId.get(orderId) === undefined ? undefined : this.#byRef.get(this.#byIbId.get(orderId) as string); if (rec === undefined || rec.terminal) return; this.#ensurePlaced(rec); this.#drain(); }); this.#on( EventName.orderStatus, (orderId: number, status: OrderStatus, filled: number, remaining: number, _avg: number) => { const ref = this.#byIbId.get(orderId); const rec = ref === undefined ? undefined : this.#byRef.get(ref); if (rec === undefined || rec.terminal) return; rec.remainingQty = remaining; void filled; // CUMULATIVE and duplicated by IB — `execDetails` is the fill truth, not this. switch (status) { case OrderStatus.PendingSubmit: case OrderStatus.PreSubmitted: case OrderStatus.Submitted: case OrderStatus.ApiPending: case OrderStatus.PendingCancel: this.#ensurePlaced(rec); break; case OrderStatus.Filled: // The fills themselves arrive as `execDetails` (per-execId, so partial + multi both work). // This status only closes the record — emitting a `fill` here would DOUBLE-COUNT. // // And it closes it ONLY on a COMPLETE fill. `filledQty > 0` latched a PARTIAL as filled, // after which every remaining execution on the order was dropped as post-terminal — the // engine would never learn of the rest of its own fill. this.#ensurePlaced(rec); if (rec.filledQty >= rec.submittedQty) this.#terminate(rec, "filled"); break; case OrderStatus.Cancelled: case OrderStatus.ApiCancelled: this.#ensurePlaced(rec); this.#emit("cancel", rec, rec.intent.px, `venue status ${status}`); this.#terminate(rec, "cancelled"); break; case OrderStatus.Inactive: // IB's other reject face: received, then made inactive (rejected / killed by the system). this.#ensurePlaced(rec); this.#emit("reject", rec, rec.intent.px, `venue status ${status}`); this.#terminate(rec, "rejected"); break; case OrderStatus.Unknown: // Never a silent shrug: log it, but do NOT invent a terminal state for the order. this.#log(`ib orderStatus Unknown for ${rec.ref} [ibOrderId=${orderId}] — no state inferred`); break; default: break; } this.#drain(); }, ); this.#on(EventName.execDetails, (_reqId: number, _c: Contract, execution: Execution) => { // THE FILL TRUTH. Each partial fill carries its own execId, so this is idempotent under IB's // replays AND naturally handles partial + multi-fill (one ORDER `fill` per distinct execution). const orderId = execution.orderId; const execId = execution.execId; if (orderId === undefined || execId === undefined) return; // an unattributable execution: never guessed onto an order if (this.#seenExecs.has(execId)) return; // replay — never double-count const ref = this.#byIbId.get(orderId); const rec = ref === undefined ? undefined : this.#byRef.get(ref); if (rec === undefined) return; // not ours (another client id on the shared account) const shares = execution.shares; const price = execution.price; if (shares === undefined || price === undefined || shares <= 0) return; // incomplete: never completed by hand this.#seenExecs.add(execId); rec.execIds.add(execId); // so the later commissionReport (which names only the execId) finds its order // A fill on an order we have ALREADY LATCHED TERMINAL (rejected/cancelled/filled) is a BREAK, not // an event: the venue executed something we believe is dead. Broker truth is still truth, so the // shares FOLD into the position — but no ORDER event is emitted after a terminal (replay // stability), and the break trips below. const postTerminal = rec.terminal; rec.filledQty += shares; rec.fillNotional += shares * price; rec.avgFillPx = rec.fillNotional / rec.filledQty; // The BROKER's own execution folds into the BROKER's own position (its truth, not ours). const key = positionKeyOf(rec.intent); const signed = rec.intent.side === "buy" ? shares : -shares; this.#execPositions.set(key, (this.#execPositions.get(key) ?? 0) + signed); // This fill CONSUMED that much of the working sell's reservation — the long it spoke for is gone // from broker truth now, so holding the reservation too would double-count it. this.#release(rec, shares); if (!postTerminal) { // Causal order: a fill can never precede its own `place`, even if IB delivers it that way. this.#ensurePlaced(rec); // The ORDER `fill` the engine OBSERVES — at the price the VENUE actually printed. this.#emit("fill", rec, price, undefined, shares); // TERMINAL only when the venue has executed EVERYTHING we submitted. Latching on the first // partial (`filledQty > 0`) would strand the remainder: the record goes terminal and every // later execution on it is dropped as post-terminal. if (rec.filledQty >= rec.submittedQty) this.#terminate(rec, "filled"); } else { this.#log( `POST-TERMINAL execution ${execId} on ${rec.ref} (phase=${rec.phase}) — folded into broker truth, NOT emitted`, ); } // FAIL CLOSED AT OBSERVATION — not on an opt-in reconcile() nobody calls. this.#tripOnObservation(rec, execId, postTerminal); this.#drain(); }); this.#on(EventName.commissionReport, (report: CommissionReport) => { // NOT an ORDER event — the vocabulary is CLOSED and a commission is not an order action. It // folds onto the LEDGER, where the reconciliation reads the venue's full account of the trade. const execId = report.execId; const commission = report.commission; if (execId === undefined || commission === undefined) return; if (this.#seenCommissions.has(execId)) return; // replay const rec = [...this.#byRef.values()].find((r) => this.#execBelongsTo(r, execId)); if (rec === undefined) return; this.#seenCommissions.add(execId); rec.commissionUsd += commission; }); this.#on(EventName.error, (err: Error, code: number, reqId?: number) => { // IB multiplexes ORDER-scoped messages onto the same `error` event the transport listens to. A // fatal one for an order id we own is a CLAIM about that order — but NOT every such claim is a // venue-confirmed terminal. if (reqId === undefined || isNonFatalError(code, err)) return; const ref = this.#byIbId.get(reqId); const rec = ref === undefined ? undefined : this.#byRef.get(ref); if (rec === undefined || rec.terminal) return; // ROUTE A: a CANCEL-REJECTION (10147/10148 "OrderId ... cannot be cancelled, state: ...") means // the order is STILL LIVE at the venue. Terminating/releasing on it frees a reservation for a // live order — reopening the exact naked short blocker 1 closed. An error of UNKNOWN/still-live // fate must NEVER free a reservation or latch a terminal: log it and await the venue's own // terminal orderStatus, the single source of a release (the round-1 fix's own principle for // cancel(), applied to errors too). if (isStillLiveOrderError(code, err)) { this.#log( `ib error ${code} on ${rec.ref} [ibOrderId=${reqId}] is a CANCEL-REJECTION — the order is STILL LIVE, not terminal. Keeping its reservation and awaiting the venue's terminal status (Route A, fail-closed): ${err.message}`, ); return; } // A routine CANCEL (IB 202 "Order Canceled - reason:") is a CANCEL, not a REJECT — mapping it onto // reject makes the Blotter learn the wrong terminal. Emit the right one. if (code === IB_ORDER_CANCELLED) { this.#emit("cancel", rec, rec.intent.px, `ib error ${code}: ${err.message}`); this.#terminate(rec, "cancelled"); this.#drain(); return; } // A genuine order REJECT (201, etc.): terminal, and it releases the reservation. this.#emit("reject", rec, rec.intent.px, `ib error ${code}: ${err.message}`); this.#terminate(rec, "rejected"); this.#drain(); }); this.#on(EventName.position, (_account: string, contract: Contract, pos: number, _avgCost?: number) => { // The ACCOUNT's own statement about itself — the highest authority there is. It REBASES this // key: `pos` becomes the new BASELINE, and the execution fold RESTARTS from it (the statement // already contains everything printed up to it). It does NOT overwrite the fold — that is how a // fill we had already OBSERVED got silently discarded from the risk input (blocker 2), and a // discarded sell-fill reads as a long we no longer own. const key = positionKeyFromContract(contract); if (key === undefined) return; // ROUTE B: SEQUENCE the rebase against what has already been folded. reqPositions() fires at // construction and IB's snapshot is ASYNC, so a push carrying a PRE-FILL snapshot can land AFTER // we have already folded that fill. If it did, `pos` re-asserts the baseline the executions were // folded ON TOP of — accepting it (and zeroing the fold) would RE-INFLATE a position we have sold // down and MASK the trip. Detect that exact staleness — the push equals the prior baseline while // executions have been folded since — and IGNORE the rebase, keeping the more-conservative // (more-short) exec view. A fresh push (a real change) differs from the prior baseline and rebases // normally. Ignoring a re-inflating push only ever errs toward LESS long / more short — fail-closed. const foldSince = this.#execPositions.get(key) ?? 0; const priorBaseline = this.#venueBaseline.has(key) ? (this.#venueBaseline.get(key) as number) : (this.#seed[key] ?? 0); if (foldSince !== 0 && pos === priorBaseline) { this.#log( `STALE position push for ${key}: pos ${pos} equals the prior baseline while ${foldSince} has already been folded on top — IGNORING the rebase so a sold-down position is not re-inflated (Route B, fail-closed)`, ); } else { this.#venueBaseline.set(key, pos); this.#execPositions.set(key, 0); } // ROUTE D: the break scan runs at EVERY observation of broker truth — the position push included, // not only inside execDetails. The position handler is the venue's statement of record, and a // guard nobody invokes at this observation point is decoration. A negative baseline (a naked // short, however it arrived) trips here. this.#scanBrokerTruthForBreaks(); this.#drain(); }); } detach(): void { for (const [event, listener] of this.#registered) { this.#client.removeListener(event, listener); } this.#registered = []; } // ── internals ─────────────────────────────────────────────────────────────── /** Does `execId` belong to this record? (The commission report names only the execution.) */ #execBelongsTo(rec: MutableRecord, execId: string): boolean { return rec.execIds.has(execId); } /** Emit `place` at most ONCE per ref, whatever order the venue's callbacks arrive in — so a `fill` * that beats its acknowledgment still reads as place→fill, and a replay adds nothing. */ #ensurePlaced(rec: MutableRecord): void { if (rec.placed) return; rec.placed = true; rec.phase = "working"; this.#emit("place", rec, rec.intent.px); } /** Latch a terminal phase — a terminal order never emits again (replay stability) — and RELEASE * whatever this order still had reserved. A cancelled/rejected sell never sold anything, so the long * it spoke for is sellable again; a filled one has had its reservation consumed fill-by-fill, and * this releases any residue (e.g. a partial that the venue then cancelled). */ #terminate(rec: MutableRecord, phase: Extract): void { rec.phase = phase; rec.terminal = true; this.#release(rec, rec.reservedQty); } /** Give back `qty` of this order's working-SELL reservation (never more than it still holds). */ #release(rec: MutableRecord, qty: number): void { if (rec.reservedQty <= 0 || qty <= 0) return; const released = Math.min(qty, rec.reservedQty); rec.reservedQty -= released; const key = positionKeyOf(rec.intent); const next = (this.#workingSellQty.get(key) ?? 0) - released; if (next > 0) this.#workingSellQty.set(key, next); else this.#workingSellQty.delete(key); } /** * FAIL CLOSED **AT OBSERVATION** (kestrel-7o2.8, blocker 3). The deepest lesson of the adversarial * gate: {@link IbkrPaperBroker.reconcile} existed, was tested, and was NEVER CALLED in production — * so an over-fill left `positions()` at −3 (a naked short in this module's OWN accounting) while the * kill-switch stayed un-tripped and the NEXT submit was still accepted. A guard nobody invokes is * decoration. So the trip fires HERE, inside the `execDetails` handler, the moment the venue's own * report proves a break: * * - a POST-TERMINAL fill (the venue executed an order we had latched rejected/cancelled/filled); * - an OVER-FILL (the venue executed MORE than we submitted); * - ANY key driven NEGATIVE — an uncovered short, however it got there (via {@link * IbkrPaperBroker.scanBrokerTruthForBreaks}, which ALSO runs at the position-push observation). * * `reconcile()` survives as an explicit AUDIT; it is simply no longer the only thing that can trip. */ #tripOnObservation(rec: MutableRecord, execId: string, postTerminal: boolean): void { if (this.killSwitch.tripped) return; if (postTerminal) { this.killSwitch.trip( `reconciliation break (POST-TERMINAL FILL) on ${rec.ref} [ibOrderId=${rec.ibOrderId}, execId=${execId}]: the venue EXECUTED an order this face had already latched ${rec.phase} — the BROKER's report is authoritative, so this is a break, not an event. Halting paper transmission (ADR-0034 §4, fail-closed)`, ); return; } if (rec.filledQty > rec.submittedQty + this.#tolerance) { this.killSwitch.trip( `reconciliation break (OVER-FILL) on ${rec.ref} [ibOrderId=${rec.ibOrderId}]: engine submitted ${rec.submittedQty}, broker reported executing ${rec.filledQty} — the BROKER's report is authoritative; halting paper transmission (ADR-0034 §4, fail-closed)`, ); return; } this.#scanBrokerTruthForBreaks(); } /** * The BREAK SCAN over broker truth (kestrel-7o2.8, Route D). Runs at EVERY observation of broker * truth — the `execDetails` fold AND the `position` push, the venue's statement of record. "A guard * nobody invokes is decoration" applies to EVERY observation point, not just one. * * A key driven NEGATIVE is an UNCOVERED SHORT. Under the no-uncovered-short POLICY (default ON — the * 0DTE book), that is the one thing no code path may leave standing, so it trips. When the policy is * OFF (an author deliberately sells puts, kestrel-buos), a negative key is a LEGITIMATE budgeted * short — the submit-time bounded-risk wall already vetted it — so mere negativity is not a break and * does not trip here. */ #scanBrokerTruthForBreaks(): void { if (this.killSwitch.tripped) return; if (!this.#noUncoveredShort) return; // shorts are allowed when the policy is off — not a break for (const [key, qty] of Object.entries(this.positions())) { if (qty < -this.#tolerance) { this.killSwitch.trip( `reconciliation break (NEGATIVE POSITION) at ${key}: the broker's own report now leaves ${qty} — an UNCOVERED SHORT, which the no-uncovered-short policy (ON) forbids. Halting paper transmission (fail-closed)`, ); return; } } } /** Append one seq-less ORDER event, mirroring `SimFillEngine`'s `#emit` field-for-field. The owner * (the emitted stream, via the injected `drain`) stamps `seq`; the clock is the `now` the driver * pinned on this gate (RUNTIME §0 — never a wall clock). */ #emit(action: OrderAction, rec: MutableRecord, px: number, reason?: string, qty?: number): void { const intent = rec.intent; this.#events.push({ ts: this.now, 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: qty ?? intent.qty, ...(intent.strike !== undefined ? { strike: intent.strike } : {}), ...(intent.right !== undefined ? { right: intent.right } : {}), px, ...(reason !== undefined ? { reason } : {}), }); } /** Register a narrowly-typed listener and remember it for {@link detach}. */ #on(event: EventName, listener: (...args: A) => void): void { const wrapped = listener as unknown as IbListener; this.#client.on(event, wrapped); this.#registered.push([event, wrapped]); } } /** The ledger line's mutable interior (the public {@link IbkrOrderRecord} is a read-only projection). */ interface MutableRecord { readonly ref: string; readonly ibOrderId: number; readonly intent: OrderIntent; readonly contract: IbkrContract; readonly submittedQty: number; filledQty: number; /** Running sum of `shares x price` — the size-weighted average's numerator. */ fillNotional: number; avgFillPx: number | undefined; commissionUsd: number; remainingQty: number | undefined; phase: OrderPhase; placed: boolean; terminal: boolean; /** The venue's `placeOrder` THREW after this record was created — the order's fate is UNKNOWN (it may * be LIVE at the venue). Its correlation and reservation are KEPT rather than erased, so a fill still * folds and the long stays guarded (Route C). Informational; the guarantees come from the retained * `#byRef`/`#byIbId`/reservation, not from this flag. */ unknownFate?: boolean; /** How much of this order's SELL quantity is still RESERVED against its key (never-naked's memory). * `qty` at submit for a sell, `0` for a buy; drawn down by each fill, zeroed at the terminal. */ reservedQty: number; readonly execIds: Set; } // ───────────────────────────────────────────────────────────────────────────── // Pure helpers. // ───────────────────────────────────────────────────────────────────────────── /** The exact IB {@link Contract} to transmit against — built from the GATEWAY's own definition * (7o2.6), not hand-rolled. `conId` is the authoritative handle; the option carries the VENUE's * MULTIPLIER (never an assumed 100x) and its own OCC local symbol. NO price rides on it, ever. */ export function ibContractOf(c: IbkrContract): Contract { if (c.kind === "option") { return { conId: c.conId, symbol: c.symbol, secType: SecType.OPT, exchange: c.exchange, currency: c.currency, localSymbol: c.localSymbol, lastTradeDateOrContractMonth: c.expiry, strike: c.strike, right: c.right === "C" ? OptionType.Call : OptionType.Put, multiplier: c.multiplier, ...(c.tradingClass === undefined ? {} : { tradingClass: c.tradingClass }), }; } return { conId: c.conId, symbol: c.symbol, secType: SecType.STK, exchange: c.exchange, currency: c.currency, localSymbol: c.localSymbol, ...(c.primaryExchange === undefined ? {} : { primaryExch: c.primaryExchange }), }; } /** The {@link PositionKey} for an IB-reported contract (the `position` push). Fail-closed: a contract * we cannot key (no symbol, or a half-specified option) is IGNORED rather than keyed by a guess — an * invented key would corrupt the very reconciliation it is meant to feed. */ function positionKeyFromContract(c: Contract): PositionKey | undefined { const symbol = c.symbol; if (symbol === undefined || symbol.length === 0) return undefined; if (c.secType === SecType.OPT) { const strike = c.strike; const right = c.right === OptionType.Call ? "C" : c.right === OptionType.Put ? "P" : undefined; if (strike === undefined || right === undefined) return undefined; return positionKeyOf({ instrument: symbol, strike, right }); } return positionKeyOf({ instrument: symbol }); } /** A seq-less ORDER event — the exact `NewBusEvent` variant `SimFillEngine` appends. */ type NewOrderEvent = Extract; // ───────────────────────────────────────────────────────────────────────────── // Construction + the mode-keyed factory registration. // ───────────────────────────────────────────────────────────────────────────── /** * Build the IBKR **PAPER** order face (kestrel-7o2.8). REFUSES `mode: "live"` at construction — live * routing is 7o2.10 and requires the human-signed {@link LiveArm}; a config flag can never grant it. */ export function ibkrBroker(cfg: IbkrConfig, deps: IbkrBrokerDeps): IbkrBroker { return new IbkrPaperBroker(cfg, deps); } /** The name the IBKR paper face registers itself under in the mode-keyed `makeGate` factory. */ export const IBKR_PAPER_VENUE = "ibkr"; /** * Register a constructed IBKR paper face as the venue `makeGate("paper", { venue: IBKR_PAPER_VENUE })` * serves (kestrel-7o2.8). This is the composition-root wiring, and it lives HERE rather than in * `adapters/broker.ts` on purpose: the core seam must never import a socket transport, so the `sim` * gate keeps its zero-dependency, byte-identical path. Registration is PAPER-only by construction — * `makeGate("live", …)` still refuses with `LiveGateRefused` no matter what is registered. */ export function installIbkrPaperVenue(broker: IbkrBroker): IbkrBroker { registerPaperVenue(IBKR_PAPER_VENUE, () => broker); return broker; }