kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
735 lines • 77.5 kB
JavaScript
/**
* # 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 { ClampRefused, makeKillSwitch, positionKeyOf, registerPaperVenue } from "../../broker.js";
import { describeIbkrConfig } from "./config.js";
/**
* 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 {
name = "IbkrOrderRefused";
failure;
/** The refused order's engine ref, when the refusal belongs to one. */
ref;
constructor(failure, reason, options = {}) {
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;
}
}
/**
* 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) {
return transport.client();
}
/** 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) {
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 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) {
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;
}
/** 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, err) {
if (IB_CANCEL_REJECTION_CODES.has(code))
return true;
return /cannot be cancel|still live|, ?state:/i.test(err.message);
}
class IbkrPaperBroker {
now = 0;
#cfg;
#client;
#contracts;
#drain;
#limits;
#budgetUsd;
#noUncoveredShort;
#intrinsicOf;
#priceAnchorOf;
#expectedPositions;
#settlementOf;
#nextOrderId;
#tolerance;
#tif;
#log;
killSwitch;
#events = [];
/** ref → the ledger line. */
#byRef = new Map();
/** IB order id → ref (the inbound pump's correlation). */
#byIbId = new Map();
/** Executions already folded, by `execId` — IB replays them, and a fill must never double-count. */
#seenExecs = new Set();
/** Commissions already folded, by `execId` (same replay hazard). */
#seenCommissions = new Set();
/** BROKER truth, folded from the venue's OWN executions (`execDetails`) SINCE that key's baseline. */
#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.
*/
#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.
*/
#workingSellQty = new Map();
/** The opening book a resumed session was seeded with. */
#seed;
/** Disappearances accounted for as CASH SETTLEMENTS (kestrel-7o2.24) — one per key, at most. */
#settlements = new Map();
#registered = [];
constructor(cfg, deps) {
// 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 ?? (() => { });
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() {
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() {
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) {
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) {
// ── 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;
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 = {
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) {
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 = { ...ticket.ibOrder, orderId: ibOrderId };
const rec = {
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) {
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() {
const snap = {};
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() {
return [...this.#byRef.values()].map((r) => ({
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() {
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() {
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, expected, actual) {
// (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