From 4bd28ea9c8979fe02c882c9b20e133d17634ee98 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:07:58 +0000 Subject: [PATCH 01/13] docs(ibkr): cpapi write-path wire-layer design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for the second oath-adapter-ibkr slice: the CP API v1 order write path (place / reply-confirm / cancel / status / live-orders) as a pure wire layer — request-body serde DTOs, the order/reply union response, and Endpoint descriptors mirroring IBKR JSON. No transport/auth/domain translation; ADR-0022/0026 untouched. Fixtures live-captured in-slice. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-07-11-ibkr-cpapi-writepath-wire-design.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md diff --git a/docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md b/docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md new file mode 100644 index 0000000..25d0810 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md @@ -0,0 +1,277 @@ +# IBKR CPAPI write-path wire layer (order place / cancel / status) (2026-07-11) + +The **second slice of the IBKR venue adapter**: extend the existing `cpapi` module +with the **order write path** as a transport-agnostic, IBKR-internal **wire layer** — +request-body serde DTOs, response DTOs (including IBKR's order/reply confirmation +union), and `Endpoint` descriptors that mirror Client Portal API v1 (CPAPI) JSON +verbatim. This slice **captures real paper-gateway responses in-band**: it drives the +stateful order dance (place → confirm → cancel) against a logged-in paper account and +reconciles the DTOs to that live wire. + +- **Status:** design — awaiting review, then implementation plan. +- **Scope:** one issue, one PR (wire DTOs + endpoints + harness capture extension + + reconciled fixtures + gated live round-trip test). +- **Builds on:** the read-path wire slice + ([design](2026-07-10-ibkr-cpapi-readpath-wire-design.md); PRs #127 / #129 / #131). + Reuses the existing `CpapiError` envelope and `decode` unchanged, and the existing + `docker/cpapi/` gateway harness. + +## 1. Context & motivation + +The read-path wire slice landed `oath-adapter-ibkr` with the CP API v1 **read** +endpoints (`auth/status`, `tickle`, accounts, positions, `secdef` search/info), a +`CpapiError` envelope, a `decode` entry point, and the hand-rolled paper gateway +harness. It **deliberately deferred the order write path**, with a documented reason +([read-path spec §2](2026-07-10-ibkr-cpapi-readpath-wire-design.md)): + +> Order **write** path (place / the two-step reply-confirm / cancel / modify) — couples +> to the unbuilt order-safety contract (ADR-0022 / ADR-0026), so it would churn. + +That caveat is about the **domain-translation / order-safety half** — OATH's +`Order`/`OrderId`, idempotency, and the order-safety contract — **not** the IBKR wire +format. IBKR's CP API v1 order request/response JSON is stable and OATH-agnostic. So a +**pure wire slice** — serde DTOs mirroring IBKR's JSON, no `Order`/`OrderId`, no +idempotency or safety logic — sidesteps the churn in exactly the way the read-path wire +slice sidestepped it for reads. ADR-0022 / ADR-0026 stay untouched. + +The OATH-side foundations remain empty skeletons, unchanged since the read-path slice: +[`oath-adapter-api`](../../../crates/adapter/api/src/lib.rs) has **no `Broker` / +`DataProvider` trait**, and [`oath-model`](../../../crates/model/src/) has **no +`InstrumentId` / `Order` / `OrderId`**. This slice touches none of them — it is the +venue-side half of [ADR-0003](../../../docs/adr/0003-canonical-model-adapter-translation.md)'s +anti-corruption boundary, extended from reads to writes. + +**The defining new shape of the write path:** it is the crate's *first* `Serialize` +(request-body) direction, and it must model IBKR's **order-reply confirmation dance** — +submitting an order returns either the placement confirmation **or** a list of warning +"questions" that must be echoed back to `POST /iserver/reply/{replyId}` with +`{"confirmed": true}` before the order goes live. + +## 2. Scope + +**In:** + +- **Request-body DTOs** (the crate's first serialize direction): + - `PlaceOrderRequest { orders: Vec }` — the `POST …/orders` body + (`{"orders":[…]}`). + - `OrderRequest` — a focused, common field set (see §3.1 / §7.2). + - `ReplyConfirm { confirmed: bool }` — the `POST /iserver/reply/{id}` body. +- **Response DTOs** for the order lifecycle: + - `OrderPlaceReply` — the place/reply **union**, one all-optional struct decoded as + `Vec` (§3.1 / §7.3). + - `CancelResponse`, `OrderStatus`, `LiveOrders` + `LiveOrder`. +- **`Endpoint` descriptors** for place, reply-confirm, cancel, order-status, live-orders + (§3.3); adds `Method::Delete`. +- **Fixture-based unit tests** (decode + serialize) that run in `just ci`. +- **Harness extension:** `just ibkr-capture` / `docker/cpapi/capture.sh` gains the + stateful order dance to capture real, sanitized fixtures (§3.2). +- A **gated `#[ignore]` live round-trip test** (place → confirm → cancel). + +**Out (deferred on purpose):** + +- **Order modify** (`POST …/order/{orderId}`) — YAGNI for this slice; a natural later + addition once place/cancel/status are proven. +- **Any OATH-domain translation** — no `Order`/`OrderId`, no idempotency/`cOID` + generation policy, no order-state machine, no order-safety semantics (ADR-0022 / + ADR-0026 stay untouched). The wire layer *carries* `cOID` as an optional passthrough + field; it does not *own* order identity. +- **No transport, no auth** — unchanged from the read path. +- **Exotic order features** — bracket / OCA groups, trailing stops, algo params, + `secType` / `listingExchange` disambiguation. A later slice if needed. +- **WS order-status streaming** — needs a `net-ws` backend that does not exist yet. + +## 3. Architecture + +An extension of the existing `cpapi` module plus a harness capture-script extension. No +new crate, no new dependencies beyond what the read path already pulls +(`serde`/`serde_json`/`thiserror`). + +### 3.1 `cpapi` wire module — new `order.rs` + +All order DTOs live in a new `cpapi/order.rs`, re-exported from `cpapi/mod.rs`. One +cohesive module (~6 structs, request + response). If it grows unwieldy, split the +read-back types (`OrderStatus`, `LiveOrders`) into `cpapi/order_status.rs` — but start +unified. + +**Request DTOs (first `Serialize` direction):** + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct PlaceOrderRequest { pub orders: Vec } // body: {"orders":[…]} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderRequest { + pub conid: i64, + pub side: String, // "BUY" / "SELL" — no enum + #[serde(rename = "orderType")] pub order_type: String, // "LMT" / "MKT" / … + pub quantity: serde_json::Number, + pub tif: String, // "DAY" / "GTC" / … + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, // LMT + #[serde(rename = "auxPrice", skip_serializing_if = "Option::is_none")] + pub aux_price: Option, // STP + #[serde(rename = "cOID", skip_serializing_if = "Option::is_none")] + pub coid: Option, // customer order id — passthrough + #[serde(rename = "outsideRTH", skip_serializing_if = "Option::is_none")] + pub outside_rth: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReplyConfirm { pub confirmed: bool } // body: {"confirmed":true} +``` + +- **No enums for `side` / `order_type` / `tif`** — kept as `String`, consistent with the + read path's "no interpretation at the wire layer." Mapping OATH's `Side`/order types + onto these strings is the deferred translation layer's job. +- **Numbers as `serde_json::Number`** for `quantity` / `price` / `aux_price` — honors + "no premature `f64`"; the translation layer produces exact decimals from OATH + fixed-point (ADR-0023). +- **`skip_serializing_if`** on every optional so absent fields don't emit `null` + (matches how IBKR clients send sparse order bodies). +- **No bespoke `encode()` helper** (§7.1) — request DTOs derive `Serialize`; the future + transport serializes with `serde_json::to_vec`. + +**Response DTOs (all derive `Debug, Clone, Deserialize` only — no `PartialEq`; money/qty +as `serde_json::Number`; anything not guaranteed is `Option`):** + +```rust +// The place / reply-confirm union — one all-optional struct (§7.3). +// decode::>(bytes) +pub struct OrderPlaceReply { + // "question" shape (a suppressible warning to confirm) + pub id: Option, + pub message: Option>, + #[serde(rename = "isSuppressed")] pub is_suppressed: Option, + // "confirmation" shape (order accepted) + pub order_id: Option, + pub order_status: Option, + #[serde(rename = "encryptMessage")] pub encrypt_message: Option, +} + +pub struct CancelResponse { // fields reconciled from live capture + pub order_id: Option, pub msg: Option, + pub conid: Option, pub account: Option, +} +pub struct OrderStatus { /* order_id, conid, side, order_status, size fields — from live capture */ } +pub struct LiveOrders { pub orders: Vec, pub snapshot: Option } +pub struct LiveOrder { /* order_id, conid, ticker, side, status, … — from live capture */ } +``` + +The exact field lists of `CancelResponse`, `OrderStatus`, and `LiveOrder` are **pinned +by the live capture** (§3.2) — the read-path slice proved that only a real capture +reveals IBKR's quirks (misspelled keys, string-vs-int ids, object-vs-array). The +structs above are the starting shape, reconciled at capture time. + +### 3.2 Paper gateway harness — capture extension + +Extend `just ibkr-capture` / `docker/cpapi/capture.sh` with the **stateful order dance** +against a logged-in paper gateway (single 5-min session window after manual browser +login, as established for the read path): + +1. **Place** a deliberately **far-from-market resting limit** order (a marketable order + could fill; a far-off limit rests so nothing executes) → capture the response + (`order_place_questions.json` if IBKR raises a warning, else the confirmation). +2. **Confirm** any reply questions via `POST /iserver/reply/{id}` `{"confirmed":true}` → + capture (`order_reply_confirmed.json`). +3. **Read back** `GET /iserver/account/order/status/{orderId}` and + `GET /iserver/account/orders` → capture (`order_status.json`, `live_orders.json`). +4. **Cancel** `DELETE /iserver/account/{acct}/order/{orderId}` → capture + (`order_cancel.json`). + +- **Forcing the questions branch:** IBKR raises confirmable warnings only for certain + precautions (order value / size caps, price-vs-market), so a plain far-off limit may + return a confirmation directly with no question. The capture drives a known precaution + (e.g. an order value / size that trips a suppressible warning) to capture a real + questions-shape response; if no precaution can be triggered live, the questions fixture + is authored as a **documented representative** one (its shape is well known and covered + by the union struct) while the confirmation, status, live-orders, and cancel fixtures + remain live-captured. +- **Safety:** far-off limit price + immediate cancel so nothing executes; paper account + only. The capture script asserts the cancel succeeded (leaves no resting order). +- **Sanitization** (documented pass, only sanitized fixtures committed): account → + `DU0000000`; order ids / `replyId` / `cOID` → stable placeholders; timestamps → fixed + placeholder. `conid`s kept (public reference data), per the read-path scrub policy. +- Fixtures land in `crates/adapter/ibkr/tests/fixtures/cpapi/`. + +### 3.3 `Endpoint` descriptors (added to `cpapi/endpoint.rs`) + +Alongside the existing read constructors; adds `Method::Delete` to the `Method` enum. + +| Constructor | Method + path | +| --- | --- | +| `place_orders(account_id)` | `POST /iserver/account/{account_id}/orders` | +| `reply(reply_id)` | `POST /iserver/reply/{reply_id}` | +| `cancel_order(account_id, order_id)` | `DELETE /iserver/account/{account_id}/order/{order_id}` | +| `order_status(order_id)` | `GET /iserver/account/order/status/{order_id}` | +| `live_orders()` | `GET /iserver/account/orders` | + +Request **bodies** remain out of the `Endpoint` descriptor (method + path only, as the +read path established); the body is the separately-modeled request DTO the future +transport serializes. + +## 4. Testing strategy (TDD, fixture-driven) + +- **Red → green per DTO:** a test deserializes `tests/fixtures/cpapi/.json` into + the target type and asserts key fields → fails (type absent) → define the DTO → green. + Fixtures are **real, sanitized paper-gateway responses** (captured via §3.2). +- **Serialize tests:** assert `OrderRequest` / `PlaceOrderRequest` / `ReplyConfirm` + produce the exact wire JSON — field renames (`orderType`, `auxPrice`, `cOID`, + `outsideRTH`), and that absent optionals are omitted (not `null`). +- **Union coverage:** decode both a questions-shape and a confirmation-shape fixture into + `Vec`, asserting the right fields are `Some` / `None` in each. +- **Gated live round-trip test:** `#[ignore]` (needs a live authenticated gateway). + Drives place → confirm → cancel via `curl -k` and decodes each live response with the + DTOs. `#[ignore]` keeps it out of `just ci` regardless of `--all-features` (same + rationale as the read-path live test). Documented run command. +- **CI:** only fixture-based unit tests run in `just ci`; the DoD stays green offline. + +## 5. Workspace / lint conformance + +- Compiles under `[workspace.lints]`: no `unsafe`, **no `unwrap`/`expect`/indexing** in + non-test code, `missing_docs` satisfied (every new public item documented), edition + 2024 / MSRV 1.90. +- No new dependencies (`serde`/`serde_json`/`thiserror` already present). + `cargo-deny` / `typos` / `cargo-machete` / `shellcheck` (capture script) clean. +- Update the [README](../../../README.md) crate-table row for `oath-adapter-ibkr` (read + path → "read + order write path"). + +## 6. Deliverables / Definition of done + +- `cpapi/order.rs` (request + response DTOs) + order `Endpoint` constructors in + `cpapi/endpoint.rs`, re-exported from `cpapi/mod.rs`. +- Extended `docker/cpapi/capture.sh` / `just ibkr-capture` running the order dance; + reconciled, sanitized fixtures committed. +- Fixture decode + serialize tests passing in `just ci`. +- Gated live round-trip test present, excluded from CI. +- README crate-table row updated; `CHANGELOG.md` `[Unreleased]` entry added. +- `just ci` green — **including `just doc`** (broken intra-doc links pass + check/lint/test but fail doc). + +## 7. Decisions (settled 2026-07-11) + +1. **Pure-wire boundary — no order-safety coupling.** Model IBKR's order request/response + JSON only. No OATH `Order`/`OrderId`, no idempotency/`cOID` policy, no order-state + machine, no ADR-0022/0026 dependency. This is why the write path — deferred by the + read-path spec as "churn-prone" — is safe to build now: the churn risk was in the + translation half, which stays out. +2. **No `encode()` helper; focused request field set.** Request DTOs derive `Serialize`; + the future transport serializes them (`decode` earns its place because response bytes + need error mapping — outgoing well-typed requests do not). `OrderRequest` carries the + common fields (`conid`, `side`, `orderType`, `quantity`, `tif`, `price`, `auxPrice`, + `cOID`, `outsideRTH`); exotic order features are a later slice (YAGNI). +3. **Place/reply union — one all-optional struct, not an untagged enum.** IBKR returns + *either* warning "questions" *or* order confirmations from both the place and the + reply endpoints. Model each array element as a single `OrderPlaceReply` carrying both + shapes' fields as `Option`, decoded as `Vec`; the caller inspects + which fields are present. This matches the read path's faithful-mirror / + no-interpretation philosophy, is a single `decode`, and avoids serde `untagged`'s + order-sensitivity and poor error messages. +4. **Live-capture in-slice, not representative-then-reconcile.** Unlike the read path's + two-PR rhythm (#127 representative → #129 live-reconciled), this slice captures real + sanitized fixtures in-band by driving the actual order dance, and reconciles the DTOs + to the live wire immediately — one self-contained PR. Requires a logged-in paper + gateway and tolerates real paper-order side effects (mitigated by a far-off resting + limit + immediate cancel). +5. **Modify deferred.** `POST …/order/{orderId}` (modify) is out of scope; place + + reply-confirm + cancel + status + live-orders is the "submission / cancel / status" + surface. Modify is a clean later addition. From ba35ebb84fd78c6d5552c30d04cf80d1965aa67f Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:53:51 +0000 Subject: [PATCH 02/13] docs(ibkr): cpapi write-path implementation plan Task-by-task TDD plan for the CP API v1 order write path: Endpoint constructors + Method::Delete, request DTOs (first serialize direction), the OrderPlaceReply union, cancel/status/live-orders response DTOs, capture.sh order-dance extension, live-capture reconcile, gated live round-trip test, and README/CHANGELOG. Derived from the approved spec. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-11-ibkr-cpapi-writepath-wire.md | 1027 +++++++++++++++++ 1 file changed, 1027 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md diff --git a/docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md b/docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md new file mode 100644 index 0000000..ef6205d --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md @@ -0,0 +1,1027 @@ +# IBKR CPAPI Write-Path Wire Layer — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `oath-adapter-ibkr` with the Client Portal API v1 (`cpapi`) **order write path** as a pure wire layer — request-body serde DTOs, the order/reply union response, cancel/status/live-orders response DTOs, and endpoint descriptors — plus a harness extension that captures the real place→confirm→cancel dance into sanitized fixtures, and a gated live round-trip test. + +**Architecture:** A new `cpapi/order.rs` module holds the crate's first `Serialize` (request) DTOs and the order-lifecycle response DTOs. Order `Endpoint` constructors join the existing read ones in `cpapi/endpoint.rs`. Fixtures are driven TDD-first as documented *representative* JSON, then replaced in-slice with **real, sanitized** captures by extending `docker/cpapi/capture.sh` to drive the stateful order dance against a logged-in paper gateway. No transport, no auth, no OATH-domain translation, no order-safety semantics — ADR-0022/0026 stay untouched. + +**Tech Stack:** Rust (edition 2024, MSRV 1.90), `serde`/`serde_json` derive, `thiserror`; the existing Docker Client Portal Gateway harness; `just` recipes; `curl` + `python3` for the capture dance. + +**Spec:** [docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md](../specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md). Builds on the read-path slice ([spec](../specs/2026-07-10-ibkr-cpapi-readpath-wire-design.md) / [plan](2026-07-10-ibkr-cpapi-readpath-wire.md); PRs #127/#129/#131). + +**Worktree:** already created at `.claude/worktrees/ibkr-cpapi-writepath` on branch `feat/ibkr-cpapi-writepath` (this plan and the spec are committed there). All work happens in that worktree — never switch the primary checkout's branch. + +## Global Constraints + +*Every task's requirements implicitly include this section.* + +- **Edition 2024, MSRV 1.90.** Validate with `just msrv`. +- **`#![forbid(unsafe_code)]`** is already at the crate root; keep it. +- **`just lint` runs `cargo clippy … -- -D warnings`.** For every `pub` item and `pub` field: a `///` doc comment (`missing_docs`); every type derives `Debug` (`missing_debug_implementations`); constructors returning a value get `#[must_use]` (`must_use_candidate`); public fns returning `Result` get a `/// # Errors` section. **Do not derive `PartialEq` without `Eq`** on an `Eq`-capable type (`derive_partial_eq_without_eq`) — DTOs derive `Debug, Clone, Deserialize` only (add `Serialize` on request DTOs); the endpoint `Method` enum keeps `PartialEq, Eq`. +- **`unwrap`/`expect`/indexing = warn in non-test code**, but **test code is exempt** (`.clippy.toml`). Non-test code returns `Result` / uses `.get()`. +- **No new dependencies.** `serde` (workspace, `features = ["derive"]`), `serde_json`, `thiserror` are already declared. `cargo-machete` runs in CI, so add no unused deps. +- **`serde_json` has NO `arbitrary_precision`** (workspace `serde_json = "1"`, default features). A non-integer `serde_json::Number` round-trips through `f64`, so serialize/`to_string` tests must use **round-trip-safe** literals (`1`, `185.5`) — never trailing-zero decimals like `185.50` (which serialize back as `185.5`). +- **`just test` runs `--all-features`** → a cargo feature cannot exclude a test from CI. Use **`#[ignore]`** for the live test. +- **`just doc` runs `RUSTDOCFLAGS="-D warnings" cargo doc --document-private-items`** → broken intra-doc links fail. Cross-reference DTOs with **backticked names, not `[]` links**. Run `just doc` in every task's verification. +- **`just ci`** = `fmt fmt-toml typos lint check test deny doc machete gitleaks actionlint shellcheck`. Shell scripts stay shellcheck-clean (`set -euo pipefail`, quoted vars); **no secrets** in committed files (gitleaks); committed fixtures are **sanitized** (no real account ids / order ids / names). *(`typos` was verified to not flag `tif`/`coid`/`rth`/`order_type` etc. — no `_typos.toml` change is needed.)* +- **Faithful-mirror rule (spec §7):** model each field as the wire actually sends it. Number-sent-as-string → `String`; ids/counts → `i64`; precision-sensitive numbers → `serde_json::Number`; **no** OATH-domain translation. `side`/`orderType`/`tif` stay `String` (no enums). Every response field not guaranteed present is `Option`. +- **Provisional-then-real fixtures (spec §7.4):** Tasks 2–4 ship *representative* fixtures to drive TDD; **Task 6 replaces them with real, sanitized captures** and reconciles the DTOs to the live wire. Field spellings/types marked "provisional" below are the reconcile targets. +- **Workflow:** one issue, one PR (this slice). Add a `CHANGELOG.md` `[Unreleased]` entry. `just ci` must pass before the PR. + +## File Structure + +``` +crates/adapter/ibkr/ + src/cpapi/ + endpoint.rs # MODIFY: + Method::Delete, + 5 order Endpoint constructors + order.rs # CREATE: request DTOs (Serialize) + order-lifecycle response DTOs + mod.rs # MODIFY: `pub mod order;` + re-exports; broaden module doc + tests/ + endpoint.rs # MODIFY: + order endpoint path tests + order.rs # CREATE: serialize tests + union/cancel/status/live decode tests + live.rs # MODIFY: + gated #[ignore] place->confirm->cancel round-trip + fixtures/cpapi/ + order_place_questions.json # CREATE (representative -> real) + order_reply_confirmed.json # CREATE (representative -> real) + order_cancel.json # CREATE (representative -> real) + order_status.json # CREATE (representative -> real) + live_orders.json # CREATE (representative -> real) + +docker/cpapi/ + capture.sh # MODIFY: + stateful order dance (place/reply/status/live/cancel) + README.md # MODIFY: + order-capture + safety + sanitize notes + +README.md # MODIFY: crate-table row + coming-soon line (read -> read + write) +CHANGELOG.md # MODIFY: [Unreleased] ### Added entry +``` + +--- + +### Task 1: `Method::Delete` + order `Endpoint` constructors + +**Files:** +- Modify: `crates/adapter/ibkr/src/cpapi/endpoint.rs` +- Test: `crates/adapter/ibkr/tests/endpoint.rs` + +**Interfaces:** +- Consumes: existing `Method`, `Endpoint` (read path). +- Produces: `Method::Delete`; constructors `Endpoint::place_orders(account_id: &str)`, `reply(reply_id: &str)`, `cancel_order(account_id: &str, order_id: &str)`, `order_status(order_id: &str)`, `live_orders()`. Paths are relative to the `…/v1/api` base. All id params are `&str` (path interpolation only). + +- [ ] **Step 1: Add the failing tests** + +Append to `crates/adapter/ibkr/tests/endpoint.rs`: + +```rust +#[test] +fn place_orders_is_a_post_with_account_in_path() { + let ep = Endpoint::place_orders("DU0000000"); + assert_eq!(ep.method, Method::Post); + assert_eq!(ep.path, "/iserver/account/DU0000000/orders"); +} + +#[test] +fn reply_interpolates_the_reply_id() { + let ep = Endpoint::reply("a1b2c3d4-0000"); + assert_eq!(ep.method, Method::Post); + assert_eq!(ep.path, "/iserver/reply/a1b2c3d4-0000"); +} + +#[test] +fn cancel_order_is_a_delete() { + let ep = Endpoint::cancel_order("DU0000000", "1234567890"); + assert_eq!(ep.method, Method::Delete); + assert_eq!(ep.path, "/iserver/account/DU0000000/order/1234567890"); +} + +#[test] +fn order_status_interpolates_the_order_id() { + let ep = Endpoint::order_status("1234567890"); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/iserver/account/order/status/1234567890"); +} + +#[test] +fn live_orders_is_a_get() { + let ep = Endpoint::live_orders(); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/iserver/account/orders"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p oath-adapter-ibkr --test endpoint` +Expected: FAIL — `Method::Delete` / `place_orders` / `reply` / `cancel_order` / `order_status` / `live_orders` not found. + +- [ ] **Step 3: Add `Delete` to the `Method` enum** + +In `crates/adapter/ibkr/src/cpapi/endpoint.rs`, extend `Method` (keep the existing derives and `Get`/`Post`): + +```rust + /// HTTP `POST`. + Post, + /// HTTP `DELETE`. + Delete, +``` + +- [ ] **Step 4: Add the order constructors** + +In `endpoint.rs`, inside `impl Endpoint`, after `secdef_info`: + +```rust + /// `POST /iserver/account/{account_id}/orders` — submit one or more orders. + /// The body (a `PlaceOrderRequest`) is supplied by the transport, not this descriptor. + #[must_use] + pub fn place_orders(account_id: &str) -> Self { + Self { + method: Method::Post, + path: format!("/iserver/account/{account_id}/orders"), + } + } + + /// `POST /iserver/reply/{reply_id}` — confirm a suppressible order warning + /// (body `{"confirmed":true}`, a `ReplyConfirm`). + #[must_use] + pub fn reply(reply_id: &str) -> Self { + Self { + method: Method::Post, + path: format!("/iserver/reply/{reply_id}"), + } + } + + /// `DELETE /iserver/account/{account_id}/order/{order_id}` — cancel a live order. + #[must_use] + pub fn cancel_order(account_id: &str, order_id: &str) -> Self { + Self { + method: Method::Delete, + path: format!("/iserver/account/{account_id}/order/{order_id}"), + } + } + + /// `GET /iserver/account/order/status/{order_id}` — status of a single order. + #[must_use] + pub fn order_status(order_id: &str) -> Self { + Self { + method: Method::Get, + path: format!("/iserver/account/order/status/{order_id}"), + } + } + + /// `GET /iserver/account/orders` — the account's live orders. + #[must_use] + pub fn live_orders() -> Self { + Self { + method: Method::Get, + path: "/iserver/account/orders".to_owned(), + } + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p oath-adapter-ibkr --test endpoint` +Expected: PASS (existing read tests + the 5 new order tests). + +- [ ] **Step 6: Lint + doc** + +Run: `just lint && just doc` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/adapter/ibkr/src/cpapi/endpoint.rs crates/adapter/ibkr/tests/endpoint.rs +git commit -m "feat(ibkr): cpapi order Endpoint constructors + Method::Delete" +``` + +--- + +### Task 2: Request DTOs — `OrderRequest`, `PlaceOrderRequest`, `ReplyConfirm` + +The crate's first `Serialize` (request-body) direction. + +**Files:** +- Create: `crates/adapter/ibkr/src/cpapi/order.rs` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Test: `crates/adapter/ibkr/tests/order.rs` + +**Interfaces:** +- Produces: `OrderRequest`, `PlaceOrderRequest { orders: Vec }`, `ReplyConfirm { confirmed: bool }`. Response DTOs are added to the same file in Tasks 3–4. + +- [ ] **Step 1: Write the failing serialize tests** + +`crates/adapter/ibkr/tests/order.rs`: + +```rust +//! Tests for the cpapi order write-path DTOs (request serialize + response decode). +use oath_adapter_ibkr::cpapi::{OrderRequest, PlaceOrderRequest, ReplyConfirm}; + +#[test] +fn order_request_serializes_with_renames_and_omits_absent_optionals() { + // Round-trip-safe numbers only: serde_json has no arbitrary_precision, so a + // trailing-zero decimal like 185.50 would serialize back as 185.5. + let req = OrderRequest { + conid: 265_598, + side: "BUY".to_owned(), + order_type: "LMT".to_owned(), + quantity: serde_json::Number::from(1_u64), + tif: "DAY".to_owned(), + price: Some("185.5".parse().expect("valid number")), + aux_price: None, + coid: Some("oath-0001".to_owned()), + outside_rth: Some(false), + }; + let json = serde_json::to_string(&req).expect("serializes"); + assert_eq!( + json, + r#"{"conid":265598,"side":"BUY","orderType":"LMT","quantity":1,"tif":"DAY","price":185.5,"cOID":"oath-0001","outsideRTH":false}"# + ); +} + +#[test] +fn place_order_request_wraps_orders_array() { + let req = PlaceOrderRequest { + orders: vec![OrderRequest { + conid: 265_598, + side: "SELL".to_owned(), + order_type: "MKT".to_owned(), + quantity: serde_json::Number::from(2_u64), + tif: "DAY".to_owned(), + price: None, + aux_price: None, + coid: None, + outside_rth: None, + }], + }; + let json = serde_json::to_string(&req).expect("serializes"); + assert_eq!( + json, + r#"{"orders":[{"conid":265598,"side":"SELL","orderType":"MKT","quantity":2,"tif":"DAY"}]}"# + ); +} + +#[test] +fn reply_confirm_serializes() { + let json = serde_json::to_string(&ReplyConfirm { confirmed: true }).expect("serializes"); + assert_eq!(json, r#"{"confirmed":true}"#); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p oath-adapter-ibkr --test order` +Expected: FAIL — `OrderRequest` / `PlaceOrderRequest` / `ReplyConfirm` not found. + +- [ ] **Step 3: Create `order.rs` with the request DTOs** + +`crates/adapter/ibkr/src/cpapi/order.rs`: + +```rust +//! Order write-path wire layer: request bodies for placing and confirming orders, +//! plus the order-lifecycle response DTOs (place/reply union, cancel, status, live +//! orders). Faithfully mirrors Client Portal API v1 JSON — no transport, no auth, no +//! OATH-domain translation, no order-safety semantics. +//! +//! `side`, `orderType`, and `tif` are kept as `String` (the wire's own tokens); the +//! mapping onto OATH's domain types is the deferred translation layer's job. + +use serde::{Deserialize, Serialize}; + +/// One order in a `POST /iserver/account/{account}/orders` request body. +/// +/// A focused subset of IBKR's order fields — enough for common equity orders. Exotic +/// features (bracket / OCA groups, trailing stops, algo params) are a later slice. +/// `quantity` / `price` / `aux_price` are `serde_json::Number` (no premature `f64`); +/// the translation layer produces exact values from OATH fixed-point (ADR-0023). +#[derive(Debug, Clone, Serialize)] +pub struct OrderRequest { + /// IBKR contract id. + pub conid: i64, + /// Order side — `"BUY"` or `"SELL"` (the wire's own token; no enum here). + pub side: String, + /// Order type — `"LMT"`, `"MKT"`, `"STP"`, …. + #[serde(rename = "orderType")] + pub order_type: String, + /// Order quantity. + pub quantity: serde_json::Number, + /// Time in force — `"DAY"`, `"GTC"`, …. + pub tif: String, + /// Limit price (for `LMT`), when applicable. + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + /// Stop / auxiliary price (for `STP`), when applicable. + #[serde(rename = "auxPrice", skip_serializing_if = "Option::is_none")] + pub aux_price: Option, + /// Customer order id (`cOID`) — a client-supplied idempotency tag. Carried through + /// verbatim; this layer does not generate or interpret it. + #[serde(rename = "cOID", skip_serializing_if = "Option::is_none")] + pub coid: Option, + /// Allow execution outside regular trading hours, when set. + #[serde(rename = "outsideRTH", skip_serializing_if = "Option::is_none")] + pub outside_rth: Option, +} + +/// Body of `POST /iserver/account/{account}/orders` — a batch of orders. +#[derive(Debug, Clone, Serialize)] +pub struct PlaceOrderRequest { + /// The orders to submit. + pub orders: Vec, +} + +/// Body of `POST /iserver/reply/{reply_id}` — confirm (or decline) a suppressible +/// order warning. +#[derive(Debug, Clone, Serialize)] +pub struct ReplyConfirm { + /// `true` to confirm the warning and proceed. + pub confirmed: bool, +} +``` + +- [ ] **Step 4: Wire the module + re-exports and broaden the module doc** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`: + +Change the first doc line from: + +```rust +//! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and +``` + +to: + +```rust +//! Client Portal API v1 (`cpapi`) wire layer: endpoint descriptors and serde DTOs for +//! the read path and the order write path. Endpoint descriptors and +``` + +Add `pub mod order;` after `pub mod endpoint;`, and add the re-export (response types join it in Tasks 3–4): + +```rust +pub use order::{OrderRequest, PlaceOrderRequest, ReplyConfirm}; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p oath-adapter-ibkr --test order` +Expected: PASS (3 serialize tests). + +- [ ] **Step 6: Lint + doc** + +Run: `just lint && just doc` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/adapter/ibkr/src/cpapi crates/adapter/ibkr/tests/order.rs +git commit -m "feat(ibkr): cpapi order request DTOs (first serialize direction)" +``` + +--- + +### Task 3: Place/reply union response — `OrderPlaceReply` + +**Files:** +- Modify: `crates/adapter/ibkr/src/cpapi/order.rs` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Modify: `crates/adapter/ibkr/tests/order.rs` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/order_place_questions.json` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json` + +**Interfaces:** +- Consumes: `decode` (read-path Task 3). +- Produces: `OrderPlaceReply` — one all-optional struct decoded as `Vec`, carrying **both** the "question" shape (`id`, `message`, `is_suppressed`) and the "confirmation" shape (`order_id`, `order_status`, `encrypt_message`). *(Provisional field spellings/types — reconciled in Task 6. Note `order_id` is a **string** on the place/reply endpoints; contrast the **integer** `order_id` on status/live in Task 4 — a faithful-mirror split like the read path's `conid`.)* + +- [ ] **Step 1: Create the representative fixtures** + +`crates/adapter/ibkr/tests/fixtures/cpapi/order_place_questions.json`: + +```json +[{"id":"a1b2c3d4-0000-0000-0000-000000000000","message":["You are submitting an order without market data. Are you sure you want to submit this order?"],"isSuppressed":false}] +``` + +`crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json`: + +```json +[{"order_id":"1234567890","order_status":"PreSubmitted","encrypt_message":"1"}] +``` + +- [ ] **Step 2: Add the failing decode tests** + +Append to `crates/adapter/ibkr/tests/order.rs` (add `decode, OrderPlaceReply` to the `use` line — final import list becomes `{OrderPlaceReply, OrderRequest, PlaceOrderRequest, ReplyConfirm, decode}`): + +```rust +#[test] +fn order_place_questions_decode_as_question_shape() { + use oath_adapter_ibkr::cpapi::decode; + let replies: Vec = + decode(include_bytes!("fixtures/cpapi/order_place_questions.json")).expect("questions decode"); + let q = replies.first().expect("one reply"); + assert_eq!(q.id.as_deref(), Some("a1b2c3d4-0000-0000-0000-000000000000")); + assert_eq!(q.message.as_ref().map(Vec::len), Some(1)); + assert_eq!(q.is_suppressed, Some(false)); + // Confirmation fields are absent on a question. + assert!(q.order_id.is_none()); + assert!(q.order_status.is_none()); +} + +#[test] +fn order_reply_confirmed_decodes_as_confirmation_shape() { + use oath_adapter_ibkr::cpapi::decode; + let replies: Vec = + decode(include_bytes!("fixtures/cpapi/order_reply_confirmed.json")).expect("confirmation decode"); + let c = replies.first().expect("one reply"); + assert_eq!(c.order_id.as_deref(), Some("1234567890")); + assert_eq!(c.order_status.as_deref(), Some("PreSubmitted")); + // Question fields are absent on a confirmation. + assert!(c.id.is_none()); + assert!(c.message.is_none()); +} +``` + +Also add `OrderPlaceReply` to the top-of-file `use oath_adapter_ibkr::cpapi::{…}` import (keep the existing request-DTO names). + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cargo test -p oath-adapter-ibkr --test order order_place` +Expected: FAIL — `OrderPlaceReply` not found. + +- [ ] **Step 4: Implement `OrderPlaceReply` in `order.rs`** + +Append to `crates/adapter/ibkr/src/cpapi/order.rs`: + +```rust +/// One element of a place-order or reply-confirm response. +/// +/// The Client Portal API returns *either* a list of suppressible warning **questions** +/// (confirm each via `POST /iserver/reply/{id}`) *or* a list of order **confirmations** +/// — from both `POST …/orders` and `POST /iserver/reply/{id}`. Rather than a serde +/// `untagged` enum (order-sensitive, poor errors), this is one all-optional struct +/// carrying both shapes; the caller inspects which fields are present. `decode` it as +/// `Vec`. +/// +/// `order_id` is a **string** here; on `order/status` and `account/orders` the same +/// logical id arrives as an **integer** (`OrderStatus`, `LiveOrder`) — the faithful +/// mirror keeps each as the wire sends it. +#[derive(Debug, Clone, Deserialize)] +pub struct OrderPlaceReply { + /// Question id to echo back to `POST /iserver/reply/{id}` (question shape). + pub id: Option, + /// Human-readable warning lines (question shape). + pub message: Option>, + /// Whether this warning can be suppressed (question shape). + #[serde(rename = "isSuppressed")] + pub is_suppressed: Option, + /// Placed order id, as a string (confirmation shape). + pub order_id: Option, + /// Order status, e.g. `"PreSubmitted"` (confirmation shape). + pub order_status: Option, + /// Opaque encrypt-message token IBKR echoes on confirmation (confirmation shape). + pub encrypt_message: Option, +} +``` + +- [ ] **Step 5: Extend the re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, extend the order re-export: + +```rust +pub use order::{OrderPlaceReply, OrderRequest, PlaceOrderRequest, ReplyConfirm}; +``` + +- [ ] **Step 6: Run tests, lint, doc** + +Run: `cargo test -p oath-adapter-ibkr --test order` → PASS. +Run: `just lint && just doc` → clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/adapter/ibkr +git commit -m "feat(ibkr): cpapi OrderPlaceReply union DTO + fixtures" +``` + +--- + +### Task 4: Cancel + status + live-orders response DTOs + +**Files:** +- Modify: `crates/adapter/ibkr/src/cpapi/order.rs` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Modify: `crates/adapter/ibkr/tests/order.rs` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json` + +**Interfaces:** +- Produces: `CancelResponse`, `OrderStatus`, `LiveOrders { orders: Vec, snapshot: Option }`, `LiveOrder`. *(Provisional — reconciled in Task 6.)* The **camel-vs-snake split** is faithful to IBKR: `order/status` is snake-native (`order_id`, `order_type`, `order_status` — no renames), while `account/orders` is camel-native (`orderId`, `orderType`, `totalSize` — renamed). `order_id` is an **integer** on all three (contrast the string on place/reply, Task 3). + +- [ ] **Step 1: Create the representative fixtures** + +`crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json`: + +```json +{"order_id":1234567890,"msg":"Request was submitted","conid":265598,"account":"DU0000000"} +``` + +`crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json`: + +```json +{"order_id":1234567890,"conid":265598,"symbol":"AAPL","side":"B","order_type":"Limit","order_status":"PreSubmitted","total_size":"1","cum_fill":"0","price":"1.00","tif":"DAY"} +``` + +`crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json`: + +```json +{"orders":[{"acct":"DU0000000","conid":265598,"orderId":1234567890,"ticker":"AAPL","side":"BUY","status":"PreSubmitted","orderType":"LIMIT","totalSize":1,"price":1.5}],"snapshot":false} +``` + +- [ ] **Step 2: Add the failing decode tests** + +Append to `crates/adapter/ibkr/tests/order.rs` (extend the top import to add `CancelResponse, LiveOrders, OrderStatus`): + +```rust +#[test] +fn cancel_response_decodes() { + use oath_adapter_ibkr::cpapi::decode; + let resp: CancelResponse = + decode(include_bytes!("fixtures/cpapi/order_cancel.json")).expect("cancel decodes"); + assert_eq!(resp.order_id, Some(1_234_567_890)); + assert_eq!(resp.msg.as_deref(), Some("Request was submitted")); +} + +#[test] +fn order_status_decodes_snake_fields() { + use oath_adapter_ibkr::cpapi::decode; + let status: OrderStatus = + decode(include_bytes!("fixtures/cpapi/order_status.json")).expect("status decodes"); + assert_eq!(status.order_id, Some(1_234_567_890)); + assert_eq!(status.order_status.as_deref(), Some("PreSubmitted")); + // Sizes arrive as strings on this endpoint — kept faithfully as String. + assert_eq!(status.total_size.as_deref(), Some("1")); +} + +#[test] +fn live_orders_decode_camel_fields() { + use oath_adapter_ibkr::cpapi::decode; + let live: LiveOrders = + decode(include_bytes!("fixtures/cpapi/live_orders.json")).expect("live orders decode"); + assert_eq!(live.snapshot, Some(false)); + let o = live.orders.first().expect("one live order"); + assert_eq!(o.order_id, Some(1_234_567_890)); // renamed from "orderId" + assert_eq!(o.ticker.as_deref(), Some("AAPL")); + assert_eq!(o.order_type.as_deref(), Some("LIMIT")); // renamed from "orderType" +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cargo test -p oath-adapter-ibkr --test order` +Expected: FAIL — `CancelResponse` / `OrderStatus` / `LiveOrders` not found. + +- [ ] **Step 4: Implement the response DTOs in `order.rs`** + +Append to `crates/adapter/ibkr/src/cpapi/order.rs`: + +```rust +/// Response of `DELETE /iserver/account/{account}/order/{order_id}` — cancel ack. +#[derive(Debug, Clone, Deserialize)] +pub struct CancelResponse { + /// The cancelled order id (integer on this endpoint). + pub order_id: Option, + /// Human-readable acknowledgement, e.g. `"Request was submitted"`. + pub msg: Option, + /// Contract id, when present. + pub conid: Option, + /// Account id, when present. + pub account: Option, +} + +/// Response of `GET /iserver/account/order/status/{order_id}`. +/// +/// This endpoint is **snake_case**-native — no serde renames. Sizes arrive as strings +/// (kept faithfully as `String`); `price` is a bare `serde_json::Number`. +#[derive(Debug, Clone, Deserialize)] +pub struct OrderStatus { + /// Order id (integer on this endpoint). + pub order_id: Option, + /// Contract id, when present. + pub conid: Option, + /// Symbol, when present. + pub symbol: Option, + /// Side token (e.g. `"B"`/`"S"`), when present. + pub side: Option, + /// Order type token, when present. + pub order_type: Option, + /// Order status, when present. + pub order_status: Option, + /// Total order size, sent as a string. + pub total_size: Option, + /// Cumulative filled size, sent as a string. + pub cum_fill: Option, + /// Limit/last price, when present. + pub price: Option, + /// Time in force, when present. + pub tif: Option, +} + +/// Response of `GET /iserver/account/orders` — the account's live orders. +/// +/// This endpoint is **camelCase**-native, so each `LiveOrder` renames `orderId` / +/// `orderType` / `totalSize` — contrast the snake-native `OrderStatus`. +#[derive(Debug, Clone, Deserialize)] +pub struct LiveOrders { + /// The live orders (may be empty; a first call can return a warming snapshot). + #[serde(default)] + pub orders: Vec, + /// Whether this is a pre-warm snapshot rather than live data, when present. + pub snapshot: Option, +} + +/// One element of a `LiveOrders` response. +#[derive(Debug, Clone, Deserialize)] +pub struct LiveOrder { + /// Account id, when present. + pub acct: Option, + /// Contract id, when present. + pub conid: Option, + /// Order id (integer; camelCase `orderId` on this endpoint). + #[serde(rename = "orderId")] + pub order_id: Option, + /// Ticker symbol, when present. + pub ticker: Option, + /// Side token, when present. + pub side: Option, + /// Order status, when present. + pub status: Option, + /// Order type (camelCase `orderType` on this endpoint), when present. + #[serde(rename = "orderType")] + pub order_type: Option, + /// Total order size (camelCase `totalSize`), when present. + #[serde(rename = "totalSize")] + pub total_size: Option, + /// Limit/last price, when present. + pub price: Option, +} +``` + +- [ ] **Step 5: Extend the re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, extend the order re-export: + +```rust +pub use order::{ + CancelResponse, LiveOrder, LiveOrders, OrderPlaceReply, OrderRequest, OrderStatus, + PlaceOrderRequest, ReplyConfirm, +}; +``` + +- [ ] **Step 6: Run tests, lint, doc** + +Run: `cargo test -p oath-adapter-ibkr --test order` → PASS (all order tests). +Run: `just lint && just doc` → clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/adapter/ibkr +git commit -m "feat(ibkr): cpapi cancel/status/live-orders response DTOs + fixtures" +``` + +--- + +### Task 5: Harness capture extension — the order dance + +**Files:** +- Modify: `docker/cpapi/capture.sh` +- Modify: `docker/cpapi/README.md` + +**Interfaces:** +- Produces: `just ibkr-capture ` additionally drives place → (reply-confirm loop) → status → live-orders → cancel and writes the five order fixtures. Empirical infra — verified by shellcheck + a live run (Task 6), not a unit test. No `Justfile` change (the recipe already forwards the account arg). + +- [ ] **Step 1: Append the order dance to `capture.sh`** + +At the end of `docker/cpapi/capture.sh` (before the final `echo "DONE…"` line — keep that line last), insert: + +```bash +# ---- Order write-path capture (paper account only; has real side effects) ---- +# Places a deliberately far-below-market resting LIMIT BUY so it will NOT fill, drives +# the reply-confirm dance, reads status + live orders, then cancels. Override the +# contract with IBKR_CONID (default AAPL 265598). +if [ -n "$ACCOUNT" ]; then + CONID="${IBKR_CONID:-265598}" + # jget FILE KEY -> prints top-level element [0][KEY] of a JSON array, else "". + jget() { + python3 -c 'import json,sys +d=json.load(open(sys.argv[1])) +print(d[0].get(sys.argv[2],"") if isinstance(d,list) and d else "")' "$1" "$2" + } + + curl -fksS --max-time 30 -X POST "$BASE/iserver/account/$ACCOUNT/orders" \ + -H 'Content-Type: application/json' \ + -d "{\"orders\":[{\"conid\":$CONID,\"orderType\":\"LMT\",\"side\":\"BUY\",\"quantity\":1,\"tif\":\"DAY\",\"price\":1.00,\"outsideRTH\":false}]}" \ + -o "$OUT/order_place.json" + echo "captured order_place.json" + + reply_id=$(jget "$OUT/order_place.json" id) + if [ -n "$reply_id" ]; then + cp "$OUT/order_place.json" "$OUT/order_place_questions.json" + for _ in 1 2 3 4 5; do + curl -fksS --max-time 30 -X POST "$BASE/iserver/reply/$reply_id" \ + -H 'Content-Type: application/json' -d '{"confirmed":true}' \ + -o "$OUT/order_reply_confirmed.json" + echo "captured order_reply_confirmed.json (reply $reply_id)" + reply_id=$(jget "$OUT/order_reply_confirmed.json" id) + if [ -z "$reply_id" ]; then break; fi + done + confirm_file="$OUT/order_reply_confirmed.json" + else + echo "no reply question was raised; order_place.json IS the confirmation." + echo " -> author order_place_questions.json as a documented representative fixture." + confirm_file="$OUT/order_place.json" + fi + + order_id=$(jget "$confirm_file" order_id) + if [ -n "$order_id" ]; then + fetch GET "/iserver/account/order/status/$order_id" order_status.json + fetch GET "/iserver/account/orders" live_orders.json + curl -fksS --max-time 30 -X DELETE "$BASE/iserver/account/$ACCOUNT/order/$order_id" \ + -o "$OUT/order_cancel.json" + echo "captured order_cancel.json (cancelled order $order_id)" + rm -f "$OUT/order_place.json" + else + echo "WARNING: no order_id parsed; skipping status/live/cancel. Inspect order_place/reply output." + fi +else + echo "skipping order write-path capture: pass an account id (arg 1 or IBKR_ACCOUNT)" +fi +``` + +*(`order_place.json` is an intermediate scratch file — removed once its confirmation is captured. Only the five committed fixtures remain.)* + +- [ ] **Step 2: Update the harness README** + +In `docker/cpapi/README.md`, under the `## Capture fixtures` section (after the existing "This writes raw JSON…" paragraph), add: + +````markdown + +### Order write-path capture (paper only — has side effects) + +`just ibkr-capture ` also drives the order lifecycle: it places a **far-below- +market resting LIMIT BUY** (price `1.00`, so it will not fill), confirms any reply +warning, reads the order status + live orders, then **cancels** it. Nothing executes, +but it does place and cancel a real paper order. Override the contract with +`IBKR_CONID` (default AAPL `265598`). + +If the gateway raises no confirmable warning, `order_place.json` is the confirmation +directly and no `order_place_questions.json` is captured — author that one as a +documented representative fixture (its shape is covered by `OrderPlaceReply`). +```` + +And extend the `## Sanitize before committing (required)` list with an order-fixture bullet: + +```markdown +- in the order fixtures, replace order ids (`order_id` / `orderId`) with a placeholder + like `1234567890` and any reply `id` with a fixed UUID placeholder. +``` + +- [ ] **Step 3: Verify shellcheck + the recipe still lists** + +Run: `shellcheck docker/cpapi/capture.sh` +Expected: no findings. + +Run: `just --list | grep ibkr-capture` +Expected: the recipe is listed (unchanged). + +- [ ] **Step 4: Commit** + +```bash +git add docker/cpapi/capture.sh docker/cpapi/README.md +git commit -m "feat(ibkr): capture.sh order dance (place/confirm/status/live/cancel)" +``` + +--- + +### Task 6: Capture & commit real paper-account order fixtures + +**Files:** +- Modify: `crates/adapter/ibkr/tests/fixtures/cpapi/order_*.json`, `live_orders.json` (representative → real, sanitized) +- Modify (as needed): `crates/adapter/ibkr/src/cpapi/order.rs` + `crates/adapter/ibkr/tests/order.rs` to reconcile with real fields + +**Interfaces:** +- Consumes: the harness (Task 5) and the DTOs (Tasks 2–4). +- Produces: **real, sanitized** order fixtures the tests pass against — satisfying the spec's live-capture-in-slice DoD, and DTOs reconciled to the live wire. + +> **Human-gated:** needs a logged-in paper gateway and tolerates a real (immediately-cancelled, non-filling) paper order. Do this before merging; it replaces the representative fixtures from Tasks 3–4 with reality and reconciles any DTO field differences (the tests are your guide). + +- [ ] **Step 1: Bring up the gateway and log in** + +Run: `docker compose -f docker/cpapi/docker-compose.yml up -d --build`, then log in with paper credentials at `https://localhost:5000` (see `docker/cpapi/README.md`). Confirm `docker ps` shows the container `healthy`. + +- [ ] **Step 2: Capture the order dance** + +Run: `just ibkr-capture ` +Expected: the read fixtures refresh, and `order_place_questions.json` (if a warning was raised), `order_reply_confirmed.json`, `order_status.json`, `live_orders.json`, `order_cancel.json` are written. The script cancels the order (verify no resting order remains in the gateway UI / `live_orders.json`). + +- [ ] **Step 3: Sanitize** + +Edit each order fixture: replace account ids with `DU0000000`, order ids (`order_id`/`orderId`) with `1234567890`, any reply `id` with `a1b2c3d4-0000-0000-0000-000000000000`, and zero quantities/prices. Keep `conid`s. + +If no `order_place_questions.json` was captured (no warning raised), keep the representative one from Task 3 (documented as such). + +Run (JSON still valid): `for f in crates/adapter/ibkr/tests/fixtures/cpapi/order_*.json crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json; do python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$f"; done` +Expected: no output (all valid). + +- [ ] **Step 4: Run the fixture tests against real data; reconcile** + +Run: `cargo test -p oath-adapter-ibkr --test order` +Expected: PASS. If a real field differs from the representative shape (a `#[serde(rename)]` needed, an `Option`, a string-vs-int type, the string-vs-int `order_id` split, or the camel-vs-snake split), adjust the DTO in `src/cpapi/order.rs` and the assertion in `tests/order.rs`, honoring the faithful-mirror rule. Re-run until green. + +- [ ] **Step 5: Guard against leaked ids** + +Run: `git grep -nE '\b(DU|U)[0-9]{6,7}\b' -- crates/adapter/ibkr/tests/fixtures | grep -v 'DU0000000' || echo "clean"` +Expected: `clean` (only the `DU0000000` placeholder remains). + +- [ ] **Step 6: Lint, doc, commit** + +Run: `just lint && just doc` → clean. + +```bash +git add crates/adapter/ibkr +git commit -m "test(ibkr): real sanitized order-lifecycle fixtures + DTO reconcile" +``` + +--- + +### Task 7: Gated live round-trip test + +**Files:** +- Modify: `crates/adapter/ibkr/tests/live.rs` + +**Interfaces:** +- Consumes: `decode`, `OrderPlaceReply`, `CancelResponse`, `Endpoint`. Shells `curl -fksS` at the running gateway; `#[ignore]`d so it stays out of `just ci`. + +- [ ] **Step 1: Add the ignored round-trip test** + +Append to `crates/adapter/ibkr/tests/live.rs` (extend the existing `use oath_adapter_ibkr::cpapi::{AuthStatus, decode};` import to add `CancelResponse, OrderPlaceReply`): + +```rust +/// Drives a full place -> confirm -> cancel round-trip against a live gateway. +/// Places a far-below-market resting LIMIT BUY (will not fill), confirms any reply +/// warning, then cancels. Requires `IBKR_ACCOUNT` (paper). Override the contract with +/// `IBKR_CONID` (default AAPL 265598). +#[test] +#[ignore = "requires a live, authenticated Client Portal Gateway + IBKR_ACCOUNT (paper)"] +fn live_order_place_confirm_cancel_round_trip() { + let base = std::env::var("IBKR_GATEWAY") + .unwrap_or_else(|_| "https://localhost:5000/v1/api".to_owned()); + let account = std::env::var("IBKR_ACCOUNT").expect("set IBKR_ACCOUNT to a paper account id"); + let conid = std::env::var("IBKR_CONID").unwrap_or_else(|_| "265598".to_owned()); + + let curl = |args: &[&str]| -> Vec { + let output = Command::new("curl") + .args(["-fksS", "--max-time", "30"]) + .args(args) + .output() + .expect("curl should run"); + assert!(output.status.success(), "curl failed: {output:?}"); + output.stdout + }; + + // Place. + let body = format!( + r#"{{"orders":[{{"conid":{conid},"orderType":"LMT","side":"BUY","quantity":1,"tif":"DAY","price":1.00,"outsideRTH":false}}]}}"# + ); + let placed = curl(&[ + "-X", "POST", &format!("{base}/iserver/account/{account}/orders"), + "-H", "Content-Type: application/json", "-d", &body, + ]); + let mut replies: Vec = decode(&placed).expect("place decodes"); + + // Confirm the reply chain until an order_id appears (bounded). + for _ in 0..5 { + let Some(reply_id) = replies.first().and_then(|r| r.id.clone()) else { break }; + let confirmed = curl(&[ + "-X", "POST", &format!("{base}/iserver/reply/{reply_id}"), + "-H", "Content-Type: application/json", "-d", r#"{"confirmed":true}"#, + ]); + replies = decode(&confirmed).expect("reply decodes"); + } + let order_id = replies + .first() + .and_then(|r| r.order_id.clone()) + .expect("a confirmed order_id"); + + // Cancel — always, so the round-trip leaves no resting order. + let cancelled = curl(&[ + "-X", "DELETE", + &format!("{base}/iserver/account/{account}/order/{order_id}"), + ]); + let _resp: CancelResponse = decode(&cancelled).expect("cancel decodes"); +} +``` + +- [ ] **Step 2: Verify it compiles and is skipped by default** + +Run: `cargo test -p oath-adapter-ibkr --test live` +Expected: compiles; both live tests report `ignored` (0 run). + +Run: `just lint` +Expected: clean. + +- [ ] **Step 3: (Optional, manual) run it against the live gateway** + +With the gateway up + logged in: `IBKR_ACCOUNT= cargo test -p oath-adapter-ibkr --test live -- --ignored` +Expected: PASS (places + cancels a paper order). + +- [ ] **Step 4: Commit** + +```bash +git add crates/adapter/ibkr/tests/live.rs +git commit -m "test(ibkr): gated live place/confirm/cancel round-trip (#[ignore])" +``` + +--- + +### Task 8: README, CHANGELOG, and full CI gate + +**Files:** +- Modify: `README.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Produces: the finished PR — docs updated and `just ci` green. + +- [ ] **Step 1: Update the crate-table row** + +In `README.md`, replace the `oath-adapter-ibkr` row (currently ending "…read-path wire layer; …to follow") with: + +```markdown +| `oath-adapter-ibkr` | IBKR venue adapter — Client Portal API v1 (`cpapi`) read + order-write wire layer; `webapi` (beta OAuth) / `tws` (socket) surfaces to follow | +``` + +- [ ] **Step 2: Update the "coming soon" line** + +In `README.md`, replace the closing-paragraph sentence about the venue adapter: + +```markdown +The first venue adapter, `oath-adapter-ibkr`, covers the Client Portal API v1 read and order-write wire layers. +``` + +- [ ] **Step 3: Add the CHANGELOG entry** + +In `CHANGELOG.md`, insert an `### Added` section immediately under `## [Unreleased]` (above the existing `### Changed`): + +```markdown +### Added + +- **`oath-adapter-ibkr` — CP API v1 order write-path wire layer.** Request-body serde + DTOs (`OrderRequest`/`PlaceOrderRequest`/`ReplyConfirm` — the crate's first serialize + direction), the order/reply union response (`OrderPlaceReply`, one all-optional struct), + and cancel/status/live-orders response DTOs (`CancelResponse`, `OrderStatus`, + `LiveOrders`/`LiveOrder`), plus `Endpoint` descriptors for place / reply-confirm / + cancel / order-status / live-orders (`Method::Delete` added). No transport, auth, or + OATH-domain translation (ADR-0022/0026 untouched). Fixtures are real, sanitized paper + captures via an extended `just ibkr-capture` order dance; a gated `#[ignore]` live test + drives a place → confirm → cancel round-trip. +``` + +- [ ] **Step 4: Run the full CI gate** + +Run: `just ci` +Expected: PASS — `fmt fmt-toml typos lint check test deny doc machete gitleaks actionlint shellcheck` all green. + +- [ ] **Step 5: Commit** + +```bash +git add README.md CHANGELOG.md +git commit -m "docs(ibkr): README + CHANGELOG for cpapi order write path" +``` + +- [ ] **Step 6: Open the PR** + +Push `feat/ibkr-cpapi-writepath` and open a PR with `Closes #135`, summarizing: CP API v1 order write-path wire layer (place/reply/cancel/status/live-orders) + harness order-dance capture + gated live round-trip; no domain translation. + +--- + +## Self-Review + +**Spec coverage:** +- §2 request DTOs (`OrderRequest`/`PlaceOrderRequest`/`ReplyConfirm`) → Task 2. ✅ +- §2 response DTOs (`OrderPlaceReply` union; `CancelResponse`/`OrderStatus`/`LiveOrders`+`LiveOrder`) → Tasks 3, 4. ✅ +- §3.3 endpoint descriptors (place/reply/cancel/status/live-orders; `Method::Delete`) → Task 1. ✅ +- §3.1 module layout (new `order.rs`, re-exports, broadened `mod.rs` doc) → Tasks 2–4. ✅ +- §3.2 harness order-dance capture + safety + sanitize + questions-branch fallback → Task 5; live run → Task 6. ✅ +- §4 fixture decode + serialize tests + union coverage + gated live round-trip → Tasks 2, 3, 4, 7. ✅ +- §5 workspace/lint conformance, README update, no new deps → Global Constraints + Task 8. ✅ +- §6 DoD (order.rs + tests in `just ci`, harness extension, reconciled fixtures, gated live test, README/CHANGELOG, `just ci` green incl. `just doc`) → Tasks 6, 8. ✅ +- §7.1 pure-wire boundary (no ADR-0022/0026) → out of scope by construction (no `Order`/`OrderId`, no safety logic). ✅ +- §7.2 no `encode()` helper; focused field set; `Number` for money → Task 2. ✅ +- §7.3 all-optional union, not untagged enum → Task 3. ✅ +- §7.4 live-capture in-slice → Tasks 5, 6. ✅ +- §7.5 modify deferred → out of scope by construction (no modify endpoint/DTO). ✅ + +**Placeholder scan:** every step ships concrete file content, an exact command, and expected output. Representative fixtures in Tasks 3–4 are explicitly reconciled to reality in Task 6 (a deliberate representative-then-real flow, not a placeholder). Field spellings/types marked "provisional" are the Task-6 reconcile targets. + +**Type consistency:** `decode` (read-path Task 3) is reused verbatim. `order.rs` re-exports in `mod.rs` accrue monotonically: Task 2 exports `{OrderRequest, PlaceOrderRequest, ReplyConfirm}`; Task 3 adds `OrderPlaceReply`; Task 4 adds `{CancelResponse, LiveOrder, LiveOrders, OrderStatus}` — final set matches every `pub` item in `order.rs`. `order_id` is `Option` on `OrderPlaceReply` (place/reply) and `Option` on `OrderStatus`/`LiveOrder`/`CancelResponse` (status/live/cancel) — intentional and asserted. `LiveOrder` renames `orderId`/`orderType`/`totalSize` (camel-native endpoint); `OrderStatus` uses bare snake fields (snake-native endpoint). Request DTOs derive `Serialize`; response DTOs derive `Debug, Clone, Deserialize` (no `PartialEq`). `Method` gains `Delete` and keeps `PartialEq, Eq`. Serialize tests use round-trip-safe numbers (`1`, `185.5`) per the `arbitrary_precision`-off constraint. From e993ffe1531ec7df7e6c679f8c124645d213442e Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:13:57 +0000 Subject: [PATCH 03/13] feat(ibkr): cpapi order Endpoint constructors + Method::Delete --- crates/adapter/ibkr/src/cpapi/endpoint.rs | 57 +++++++++++++++++++++-- crates/adapter/ibkr/tests/endpoint.rs | 37 ++++++++++++++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/adapter/ibkr/src/cpapi/endpoint.rs b/crates/adapter/ibkr/src/cpapi/endpoint.rs index 9697dec..d09ac93 100644 --- a/crates/adapter/ibkr/src/cpapi/endpoint.rs +++ b/crates/adapter/ibkr/src/cpapi/endpoint.rs @@ -1,4 +1,5 @@ -//! Endpoint descriptors for the Client Portal API v1 read path. +//! Endpoint descriptors for the Client Portal API v1 — the read path and the +//! order write path. //! //! An [`Endpoint`] is a pure value — an HTTP [`Method`] plus a path *relative to the //! gateway base URL* (`https://localhost:5000/v1/api`). This layer carries no @@ -11,6 +12,8 @@ pub enum Method { Get, /// HTTP `POST`. Post, + /// HTTP `DELETE`. + Delete, } /// A Client Portal API v1 endpoint: an HTTP [`Method`] and a path relative to the @@ -18,8 +21,9 @@ pub enum Method { /// /// This descriptor models the HTTP method and the path — including any query /// string — only. Request **bodies** (for example the `secdef_search` search -/// payload `{symbol, secType}`) are supplied by the future request/transport -/// binding and are not modeled in this read-path slice. +/// payload `{symbol, secType}`, or the order-submission and reply-confirm bodies +/// of the write path) are supplied by the future request/transport binding and +/// are not modeled here. #[derive(Debug, Clone)] pub struct Endpoint { /// The HTTP method. @@ -96,4 +100,51 @@ impl Endpoint { path: format!("/iserver/secdef/info?conid={conid}"), } } + + /// `POST /iserver/account/{account_id}/orders` — submit one or more orders. + /// The body (a `PlaceOrderRequest`) is supplied by the transport, not this descriptor. + #[must_use] + pub fn place_orders(account_id: &str) -> Self { + Self { + method: Method::Post, + path: format!("/iserver/account/{account_id}/orders"), + } + } + + /// `POST /iserver/reply/{reply_id}` — confirm a suppressible order warning + /// (body `{"confirmed":true}`, a `ReplyConfirm`). + #[must_use] + pub fn reply(reply_id: &str) -> Self { + Self { + method: Method::Post, + path: format!("/iserver/reply/{reply_id}"), + } + } + + /// `DELETE /iserver/account/{account_id}/order/{order_id}` — cancel a live order. + #[must_use] + pub fn cancel_order(account_id: &str, order_id: &str) -> Self { + Self { + method: Method::Delete, + path: format!("/iserver/account/{account_id}/order/{order_id}"), + } + } + + /// `GET /iserver/account/order/status/{order_id}` — status of a single order. + #[must_use] + pub fn order_status(order_id: &str) -> Self { + Self { + method: Method::Get, + path: format!("/iserver/account/order/status/{order_id}"), + } + } + + /// `GET /iserver/account/orders` — the account's live orders. + #[must_use] + pub fn live_orders() -> Self { + Self { + method: Method::Get, + path: "/iserver/account/orders".to_owned(), + } + } } diff --git a/crates/adapter/ibkr/tests/endpoint.rs b/crates/adapter/ibkr/tests/endpoint.rs index 411e70e..bd90c32 100644 --- a/crates/adapter/ibkr/tests/endpoint.rs +++ b/crates/adapter/ibkr/tests/endpoint.rs @@ -1,5 +1,5 @@ //! Endpoint path-rendering tests — one assertion group per constructor, covering -//! all seven Client Portal API v1 read-path endpoints. +//! the Client Portal API v1 read-path and order write-path endpoints. use oath_adapter_ibkr::cpapi::{Endpoint, Method}; #[test] @@ -52,3 +52,38 @@ fn secdef_info_path_interpolates_conid_only() { assert_eq!(ep.method, Method::Get); assert_eq!(ep.path, "/iserver/secdef/info?conid=265598"); } + +#[test] +fn place_orders_is_a_post_with_account_in_path() { + let ep = Endpoint::place_orders("DU0000000"); + assert_eq!(ep.method, Method::Post); + assert_eq!(ep.path, "/iserver/account/DU0000000/orders"); +} + +#[test] +fn reply_interpolates_the_reply_id() { + let ep = Endpoint::reply("a1b2c3d4-0000"); + assert_eq!(ep.method, Method::Post); + assert_eq!(ep.path, "/iserver/reply/a1b2c3d4-0000"); +} + +#[test] +fn cancel_order_is_a_delete() { + let ep = Endpoint::cancel_order("DU0000000", "1234567890"); + assert_eq!(ep.method, Method::Delete); + assert_eq!(ep.path, "/iserver/account/DU0000000/order/1234567890"); +} + +#[test] +fn order_status_interpolates_the_order_id() { + let ep = Endpoint::order_status("1234567890"); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/iserver/account/order/status/1234567890"); +} + +#[test] +fn live_orders_is_a_get() { + let ep = Endpoint::live_orders(); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/iserver/account/orders"); +} From 9a6310bbcbe4efc07d4b6236b94e3a6bc57d94bd Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:37:37 +0000 Subject: [PATCH 04/13] feat(ibkr): cpapi order request DTOs (first serialize direction) --- crates/adapter/ibkr/src/cpapi/mod.rs | 10 +++-- crates/adapter/ibkr/src/cpapi/order.rs | 58 ++++++++++++++++++++++++++ crates/adapter/ibkr/tests/order.rs | 52 +++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 crates/adapter/ibkr/src/cpapi/order.rs create mode 100644 crates/adapter/ibkr/tests/order.rs diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 983275a..0185e67 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -1,6 +1,8 @@ -//! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and -//! serde DTOs that mirror IBKR's JSON responses. No auth, no transport, no -//! OATH-domain translation. +//! Client Portal API v1 (`cpapi`) wire layer: endpoint descriptors and serde DTOs for +//! the read path and the order write path. +//! +//! Endpoint descriptors and serde DTOs that mirror IBKR's JSON responses. No auth, +//! no transport, no OATH-domain translation. //! //! The DTOs faithfully mirror the *modeled* fields; unmodeled fields (for //! example `assetClass`, `isPaper`, `hmds`) are silently ignored, not echoed @@ -9,11 +11,13 @@ pub mod auth; pub mod endpoint; pub mod error; +pub mod order; pub mod portfolio; pub mod secdef; pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse}; pub use endpoint::{Endpoint, Method}; pub use error::{CpapiError, WireError, decode}; +pub use order::{OrderRequest, PlaceOrderRequest, ReplyConfirm}; pub use portfolio::{IServerAccounts, PortfolioAccount, Position}; pub use secdef::{SecdefInfo, SecdefSearchEntry, SecdefSection}; diff --git a/crates/adapter/ibkr/src/cpapi/order.rs b/crates/adapter/ibkr/src/cpapi/order.rs new file mode 100644 index 0000000..cf28473 --- /dev/null +++ b/crates/adapter/ibkr/src/cpapi/order.rs @@ -0,0 +1,58 @@ +//! Order write-path wire layer: request bodies for placing and confirming orders, +//! plus the order-lifecycle response DTOs (added in later tasks). +//! +//! Faithfully mirrors Client Portal API v1 JSON — no transport, no auth, no +//! OATH-domain translation, no order-safety semantics. `side`, `orderType`, and +//! `tif` are kept as `String` (the wire's own tokens); the mapping onto OATH's +//! domain types is the deferred translation layer's job. + +use serde::Serialize; + +/// One order in a `POST /iserver/account/{account}/orders` request body. +/// +/// A focused subset of IBKR's order fields — enough for common equity orders. Exotic +/// features (bracket / OCA groups, trailing stops, algo params) are a later slice. +/// `quantity` / `price` / `aux_price` are `serde_json::Number` (no premature `f64`); +/// the translation layer produces exact values from OATH fixed-point (ADR-0023). +#[derive(Debug, Clone, Serialize)] +pub struct OrderRequest { + /// IBKR contract id. + pub conid: i64, + /// Order side — `"BUY"` or `"SELL"` (the wire's own token; no enum here). + pub side: String, + /// Order type — `"LMT"`, `"MKT"`, `"STP"`, …. + #[serde(rename = "orderType")] + pub order_type: String, + /// Order quantity. + pub quantity: serde_json::Number, + /// Time in force — `"DAY"`, `"GTC"`, …. + pub tif: String, + /// Limit price (for `LMT`), when applicable. + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + /// Stop / auxiliary price (for `STP`), when applicable. + #[serde(rename = "auxPrice", skip_serializing_if = "Option::is_none")] + pub aux_price: Option, + /// Customer order id (`cOID`) — a client-supplied idempotency tag. Carried through + /// verbatim; this layer does not generate or interpret it. + #[serde(rename = "cOID", skip_serializing_if = "Option::is_none")] + pub coid: Option, + /// Allow execution outside regular trading hours, when set. + #[serde(rename = "outsideRTH", skip_serializing_if = "Option::is_none")] + pub outside_rth: Option, +} + +/// Body of `POST /iserver/account/{account}/orders` — a batch of orders. +#[derive(Debug, Clone, Serialize)] +pub struct PlaceOrderRequest { + /// The orders to submit. + pub orders: Vec, +} + +/// Body of `POST /iserver/reply/{reply_id}` — confirm (or decline) a suppressible +/// order warning. +#[derive(Debug, Clone, Serialize)] +pub struct ReplyConfirm { + /// `true` to confirm the warning and proceed. + pub confirmed: bool, +} diff --git a/crates/adapter/ibkr/tests/order.rs b/crates/adapter/ibkr/tests/order.rs new file mode 100644 index 0000000..ebc1e81 --- /dev/null +++ b/crates/adapter/ibkr/tests/order.rs @@ -0,0 +1,52 @@ +//! Tests for the cpapi order write-path DTOs (request serialize + response decode). +use oath_adapter_ibkr::cpapi::{OrderRequest, PlaceOrderRequest, ReplyConfirm}; + +#[test] +fn order_request_serializes_with_renames_and_omits_absent_optionals() { + // Round-trip-safe numbers only: serde_json has no arbitrary_precision, so a + // trailing-zero decimal like 185.50 would serialize back as 185.5. + let req = OrderRequest { + conid: 265_598, + side: "BUY".to_owned(), + order_type: "LMT".to_owned(), + quantity: serde_json::Number::from(1_u64), + tif: "DAY".to_owned(), + price: Some("185.5".parse().expect("valid number")), + aux_price: None, + coid: Some("oath-0001".to_owned()), + outside_rth: Some(false), + }; + let json = serde_json::to_string(&req).expect("serializes"); + assert_eq!( + json, + r#"{"conid":265598,"side":"BUY","orderType":"LMT","quantity":1,"tif":"DAY","price":185.5,"cOID":"oath-0001","outsideRTH":false}"# + ); +} + +#[test] +fn place_order_request_wraps_orders_array() { + let req = PlaceOrderRequest { + orders: vec![OrderRequest { + conid: 265_598, + side: "SELL".to_owned(), + order_type: "MKT".to_owned(), + quantity: serde_json::Number::from(2_u64), + tif: "DAY".to_owned(), + price: None, + aux_price: None, + coid: None, + outside_rth: None, + }], + }; + let json = serde_json::to_string(&req).expect("serializes"); + assert_eq!( + json, + r#"{"orders":[{"conid":265598,"side":"SELL","orderType":"MKT","quantity":2,"tif":"DAY"}]}"# + ); +} + +#[test] +fn reply_confirm_serializes() { + let json = serde_json::to_string(&ReplyConfirm { confirmed: true }).expect("serializes"); + assert_eq!(json, r#"{"confirmed":true}"#); +} From b055401894c401deb48cf178cb833f00f16a1af1 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:48:33 +0000 Subject: [PATCH 05/13] feat(ibkr): cpapi OrderPlaceReply union DTO + fixtures --- crates/adapter/ibkr/src/cpapi/mod.rs | 2 +- crates/adapter/ibkr/src/cpapi/order.rs | 31 ++++++++++++++++- .../fixtures/cpapi/order_place_questions.json | 1 + .../fixtures/cpapi/order_reply_confirmed.json | 1 + crates/adapter/ibkr/tests/order.rs | 34 ++++++++++++++++++- 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/order_place_questions.json create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 0185e67..8fd272d 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -18,6 +18,6 @@ pub mod secdef; pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse}; pub use endpoint::{Endpoint, Method}; pub use error::{CpapiError, WireError, decode}; -pub use order::{OrderRequest, PlaceOrderRequest, ReplyConfirm}; +pub use order::{OrderPlaceReply, OrderRequest, PlaceOrderRequest, ReplyConfirm}; pub use portfolio::{IServerAccounts, PortfolioAccount, Position}; pub use secdef::{SecdefInfo, SecdefSearchEntry, SecdefSection}; diff --git a/crates/adapter/ibkr/src/cpapi/order.rs b/crates/adapter/ibkr/src/cpapi/order.rs index cf28473..7ac1989 100644 --- a/crates/adapter/ibkr/src/cpapi/order.rs +++ b/crates/adapter/ibkr/src/cpapi/order.rs @@ -6,7 +6,7 @@ //! `tif` are kept as `String` (the wire's own tokens); the mapping onto OATH's //! domain types is the deferred translation layer's job. -use serde::Serialize; +use serde::{Deserialize, Serialize}; /// One order in a `POST /iserver/account/{account}/orders` request body. /// @@ -56,3 +56,32 @@ pub struct ReplyConfirm { /// `true` to confirm the warning and proceed. pub confirmed: bool, } + +/// One element of a place-order or reply-confirm response. +/// +/// The Client Portal API returns *either* a list of suppressible warning **questions** +/// (confirm each via `POST /iserver/reply/{id}`) *or* a list of order **confirmations** +/// — from both `POST …/orders` and `POST /iserver/reply/{id}`. Rather than a serde +/// `untagged` enum (order-sensitive, poor errors), this is one all-optional struct +/// carrying both shapes; the caller inspects which fields are present. `decode` it as +/// `Vec`. +/// +/// `order_id` is a **string** here; on `order/status` and `account/orders` the same +/// logical id arrives as an **integer** (`OrderStatus`, `LiveOrder`) — the faithful +/// mirror keeps each as the wire sends it. +#[derive(Debug, Clone, Deserialize)] +pub struct OrderPlaceReply { + /// Question id to echo back to `POST /iserver/reply/{id}` (question shape). + pub id: Option, + /// Human-readable warning lines (question shape). + pub message: Option>, + /// Whether this warning can be suppressed (question shape). + #[serde(rename = "isSuppressed")] + pub is_suppressed: Option, + /// Placed order id, as a string (confirmation shape). + pub order_id: Option, + /// Order status, e.g. `"PreSubmitted"` (confirmation shape). + pub order_status: Option, + /// Opaque encrypt-message token IBKR echoes on confirmation (confirmation shape). + pub encrypt_message: Option, +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_place_questions.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_place_questions.json new file mode 100644 index 0000000..afd6b09 --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_place_questions.json @@ -0,0 +1 @@ +[{"id":"a1b2c3d4-0000-0000-0000-000000000000","message":["You are submitting an order without market data. Are you sure you want to submit this order?"],"isSuppressed":false}] diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json new file mode 100644 index 0000000..556bdc7 --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json @@ -0,0 +1 @@ +[{"order_id":"1234567890","order_status":"PreSubmitted","encrypt_message":"1"}] diff --git a/crates/adapter/ibkr/tests/order.rs b/crates/adapter/ibkr/tests/order.rs index ebc1e81..4bbfedd 100644 --- a/crates/adapter/ibkr/tests/order.rs +++ b/crates/adapter/ibkr/tests/order.rs @@ -1,5 +1,5 @@ //! Tests for the cpapi order write-path DTOs (request serialize + response decode). -use oath_adapter_ibkr::cpapi::{OrderRequest, PlaceOrderRequest, ReplyConfirm}; +use oath_adapter_ibkr::cpapi::{OrderPlaceReply, OrderRequest, PlaceOrderRequest, ReplyConfirm}; #[test] fn order_request_serializes_with_renames_and_omits_absent_optionals() { @@ -50,3 +50,35 @@ fn reply_confirm_serializes() { let json = serde_json::to_string(&ReplyConfirm { confirmed: true }).expect("serializes"); assert_eq!(json, r#"{"confirmed":true}"#); } + +#[test] +fn order_place_questions_decode_as_question_shape() { + use oath_adapter_ibkr::cpapi::decode; + let replies: Vec = + decode(include_bytes!("fixtures/cpapi/order_place_questions.json")) + .expect("questions decode"); + let q = replies.first().expect("one reply"); + assert_eq!( + q.id.as_deref(), + Some("a1b2c3d4-0000-0000-0000-000000000000") + ); + assert_eq!(q.message.as_ref().map(Vec::len), Some(1)); + assert_eq!(q.is_suppressed, Some(false)); + // Confirmation fields are absent on a question. + assert!(q.order_id.is_none()); + assert!(q.order_status.is_none()); +} + +#[test] +fn order_reply_confirmed_decodes_as_confirmation_shape() { + use oath_adapter_ibkr::cpapi::decode; + let replies: Vec = + decode(include_bytes!("fixtures/cpapi/order_reply_confirmed.json")) + .expect("confirmation decode"); + let c = replies.first().expect("one reply"); + assert_eq!(c.order_id.as_deref(), Some("1234567890")); + assert_eq!(c.order_status.as_deref(), Some("PreSubmitted")); + // Question fields are absent on a confirmation. + assert!(c.id.is_none()); + assert!(c.message.is_none()); +} From 2102a4c9bfb5922cf9e7fa1919059e1a5621d2a1 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:56:47 +0000 Subject: [PATCH 06/13] feat(ibkr): cpapi cancel/status/live-orders response DTOs + fixtures --- crates/adapter/ibkr/src/cpapi/mod.rs | 5 +- crates/adapter/ibkr/src/cpapi/order.rs | 80 +++++++++++++++++++ .../tests/fixtures/cpapi/live_orders.json | 1 + .../tests/fixtures/cpapi/order_cancel.json | 1 + .../tests/fixtures/cpapi/order_status.json | 1 + crates/adapter/ibkr/tests/order.rs | 37 ++++++++- 6 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 8fd272d..908be21 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -18,6 +18,9 @@ pub mod secdef; pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse}; pub use endpoint::{Endpoint, Method}; pub use error::{CpapiError, WireError, decode}; -pub use order::{OrderPlaceReply, OrderRequest, PlaceOrderRequest, ReplyConfirm}; +pub use order::{ + CancelResponse, LiveOrder, LiveOrders, OrderPlaceReply, OrderRequest, OrderStatus, + PlaceOrderRequest, ReplyConfirm, +}; pub use portfolio::{IServerAccounts, PortfolioAccount, Position}; pub use secdef::{SecdefInfo, SecdefSearchEntry, SecdefSection}; diff --git a/crates/adapter/ibkr/src/cpapi/order.rs b/crates/adapter/ibkr/src/cpapi/order.rs index 7ac1989..c0eee7b 100644 --- a/crates/adapter/ibkr/src/cpapi/order.rs +++ b/crates/adapter/ibkr/src/cpapi/order.rs @@ -85,3 +85,83 @@ pub struct OrderPlaceReply { /// Opaque encrypt-message token IBKR echoes on confirmation (confirmation shape). pub encrypt_message: Option, } + +/// Response of `DELETE /iserver/account/{account}/order/{order_id}` — cancel ack. +#[derive(Debug, Clone, Deserialize)] +pub struct CancelResponse { + /// The cancelled order id (integer on this endpoint). + pub order_id: Option, + /// Human-readable acknowledgement, e.g. `"Request was submitted"`. + pub msg: Option, + /// Contract id, when present. + pub conid: Option, + /// Account id, when present. + pub account: Option, +} + +/// Response of `GET /iserver/account/order/status/{order_id}`. +/// +/// This endpoint is **`snake_case`**-native — no serde renames. Sizes arrive as strings +/// (kept faithfully as `String`); `price` is a bare `serde_json::Number`. +#[derive(Debug, Clone, Deserialize)] +pub struct OrderStatus { + /// Order id (integer on this endpoint). + pub order_id: Option, + /// Contract id, when present. + pub conid: Option, + /// Symbol, when present. + pub symbol: Option, + /// Side token (e.g. `"B"`/`"S"`), when present. + pub side: Option, + /// Order type token, when present. + pub order_type: Option, + /// Order status, when present. + pub order_status: Option, + /// Total order size, sent as a string. + pub total_size: Option, + /// Cumulative filled size, sent as a string. + pub cum_fill: Option, + /// Limit/last price, when present. + pub price: Option, + /// Time in force, when present. + pub tif: Option, +} + +/// Response of `GET /iserver/account/orders` — the account's live orders. +/// +/// This endpoint is **`camelCase`**-native, so each `LiveOrder` renames `orderId` / +/// `orderType` / `totalSize` — contrast the snake-native `OrderStatus`. +#[derive(Debug, Clone, Deserialize)] +pub struct LiveOrders { + /// The live orders (may be empty; a first call can return a warming snapshot). + #[serde(default)] + pub orders: Vec, + /// Whether this is a pre-warm snapshot rather than live data, when present. + pub snapshot: Option, +} + +/// One element of a `LiveOrders` response. +#[derive(Debug, Clone, Deserialize)] +pub struct LiveOrder { + /// Account id, when present. + pub acct: Option, + /// Contract id, when present. + pub conid: Option, + /// Order id (integer; `camelCase` `orderId` on this endpoint). + #[serde(rename = "orderId")] + pub order_id: Option, + /// Ticker symbol, when present. + pub ticker: Option, + /// Side token, when present. + pub side: Option, + /// Order status, when present. + pub status: Option, + /// Order type (`camelCase` `orderType` on this endpoint), when present. + #[serde(rename = "orderType")] + pub order_type: Option, + /// Total order size (`camelCase` `totalSize`), when present. + #[serde(rename = "totalSize")] + pub total_size: Option, + /// Limit/last price, when present. + pub price: Option, +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json b/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json new file mode 100644 index 0000000..a39da26 --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json @@ -0,0 +1 @@ +{"orders":[{"acct":"DU0000000","conid":265598,"orderId":1234567890,"ticker":"AAPL","side":"BUY","status":"PreSubmitted","orderType":"LIMIT","totalSize":1,"price":1.5}],"snapshot":false} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json new file mode 100644 index 0000000..3640f70 --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json @@ -0,0 +1 @@ +{"order_id":1234567890,"msg":"Request was submitted","conid":265598,"account":"DU0000000"} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json new file mode 100644 index 0000000..bc1d25c --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json @@ -0,0 +1 @@ +{"order_id":1234567890,"conid":265598,"symbol":"AAPL","side":"B","order_type":"Limit","order_status":"PreSubmitted","total_size":"1","cum_fill":"0","price":1.00,"tif":"DAY"} diff --git a/crates/adapter/ibkr/tests/order.rs b/crates/adapter/ibkr/tests/order.rs index 4bbfedd..28b0db0 100644 --- a/crates/adapter/ibkr/tests/order.rs +++ b/crates/adapter/ibkr/tests/order.rs @@ -1,5 +1,8 @@ //! Tests for the cpapi order write-path DTOs (request serialize + response decode). -use oath_adapter_ibkr::cpapi::{OrderPlaceReply, OrderRequest, PlaceOrderRequest, ReplyConfirm}; +use oath_adapter_ibkr::cpapi::{ + CancelResponse, LiveOrders, OrderPlaceReply, OrderRequest, OrderStatus, PlaceOrderRequest, + ReplyConfirm, +}; #[test] fn order_request_serializes_with_renames_and_omits_absent_optionals() { @@ -82,3 +85,35 @@ fn order_reply_confirmed_decodes_as_confirmation_shape() { assert!(c.id.is_none()); assert!(c.message.is_none()); } + +#[test] +fn cancel_response_decodes() { + use oath_adapter_ibkr::cpapi::decode; + let resp: CancelResponse = + decode(include_bytes!("fixtures/cpapi/order_cancel.json")).expect("cancel decodes"); + assert_eq!(resp.order_id, Some(1_234_567_890)); + assert_eq!(resp.msg.as_deref(), Some("Request was submitted")); +} + +#[test] +fn order_status_decodes_snake_fields() { + use oath_adapter_ibkr::cpapi::decode; + let status: OrderStatus = + decode(include_bytes!("fixtures/cpapi/order_status.json")).expect("status decodes"); + assert_eq!(status.order_id, Some(1_234_567_890)); + assert_eq!(status.order_status.as_deref(), Some("PreSubmitted")); + // Sizes arrive as strings on this endpoint — kept faithfully as String. + assert_eq!(status.total_size.as_deref(), Some("1")); +} + +#[test] +fn live_orders_decode_camel_fields() { + use oath_adapter_ibkr::cpapi::decode; + let live: LiveOrders = + decode(include_bytes!("fixtures/cpapi/live_orders.json")).expect("live orders decode"); + assert_eq!(live.snapshot, Some(false)); + let o = live.orders.first().expect("one live order"); + assert_eq!(o.order_id, Some(1_234_567_890)); // renamed from "orderId" + assert_eq!(o.ticker.as_deref(), Some("AAPL")); + assert_eq!(o.order_type.as_deref(), Some("LIMIT")); // renamed from "orderType" +} From 3e0e965cbce26c72afc225890679130be90c0e48 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:02:27 +0000 Subject: [PATCH 07/13] feat(ibkr): capture.sh order dance (place/confirm/status/live/cancel) --- docker/cpapi/README.md | 14 +++++ docker/cpapi/capture.sh | 62 +++++++++++++++++++ .../2026-07-11-ibkr-cpapi-writepath-wire.md | 20 ++++-- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/docker/cpapi/README.md b/docker/cpapi/README.md index 20cb4be..47d5090 100644 --- a/docker/cpapi/README.md +++ b/docker/cpapi/README.md @@ -30,9 +30,23 @@ resolves the gateway's container IP (localhost:5000 is not routable from inside devcontainer). If the script aborts on an endpoint, the brokerage session likely isn't authenticated — log in at https://localhost:5000 and re-run. +### Order write-path capture (paper only — has side effects) + +`just ibkr-capture ` also drives the order lifecycle: it places a **far-below- +market resting LIMIT BUY** (price `1.00`, so it will not fill), confirms any reply +warning, reads the order status + live orders, then **cancels** it. Nothing executes, +but it does place and cancel a real paper order. Override the contract with +`IBKR_CONID` (default AAPL `265598`). + +If the gateway raises no confirmable warning, `order_place.json` is the confirmation +directly and no `order_place_questions.json` is captured — author that one as a +documented representative fixture (its shape is covered by `OrderPlaceReply`). + ## Sanitize before committing (required) The responses come from a real paper account. Before `git add`: - replace account ids (e.g. `DU…`/`U…`) with a placeholder like `DU0000000`, - zero out balances / P&L / quantities, - remove account holder names. +- in the order fixtures, replace order ids (`order_id` / `orderId`) with a placeholder + like `1234567890` and any reply `id` with a fixed UUID placeholder. Keep `conid`s (public reference data). `gitleaks` runs in CI — no secrets. diff --git a/docker/cpapi/capture.sh b/docker/cpapi/capture.sh index bbb50ce..505df9e 100755 --- a/docker/cpapi/capture.sh +++ b/docker/cpapi/capture.sh @@ -43,4 +43,66 @@ echo "captured secdef_search.json" # secType=STK triggers 400 "month required" on a stock; pass conid alone. fetch GET "/iserver/secdef/info?conid=265598" secdef_info.json +# ---- Order write-path capture (paper account only; has real side effects) ---- +# Places a deliberately far-below-market resting LIMIT BUY so it will NOT fill, drives +# the reply-confirm dance, reads status + live orders, then cancels. Override the +# contract with IBKR_CONID (default AAPL 265598). +if [ -n "$ACCOUNT" ]; then + CONID="${IBKR_CONID:-265598}" + # jget FILE KEY -> prints top-level element [0][KEY] of a JSON array, else "". + jget() { + python3 -c 'import json,sys +d=json.load(open(sys.argv[1])) +print(d[0].get(sys.argv[2],"") if isinstance(d,list) and d else "")' "$1" "$2" + } + + curl -fksS --max-time 30 -X POST "$BASE/iserver/account/$ACCOUNT/orders" \ + -H 'Content-Type: application/json' \ + -d "{\"orders\":[{\"conid\":$CONID,\"orderType\":\"LMT\",\"side\":\"BUY\",\"quantity\":1,\"tif\":\"DAY\",\"price\":1.00,\"outsideRTH\":false}]}" \ + -o "$OUT/order_place.json" + echo "captured order_place.json" + + reply_id=$(jget "$OUT/order_place.json" id) + if [ -n "$reply_id" ]; then + cp "$OUT/order_place.json" "$OUT/order_place_questions.json" + for _ in 1 2 3 4 5; do + curl -fksS --max-time 30 -X POST "$BASE/iserver/reply/$reply_id" \ + -H 'Content-Type: application/json' -d '{"confirmed":true}' \ + -o "$OUT/order_reply_confirmed.json" + echo "captured order_reply_confirmed.json (reply $reply_id)" + reply_id=$(jget "$OUT/order_reply_confirmed.json" id) + if [ -z "$reply_id" ]; then break; fi + done + confirm_file="$OUT/order_reply_confirmed.json" + else + echo "no reply question was raised; order_place.json IS the confirmation." + echo " -> author order_place_questions.json as a documented representative fixture." + confirm_file="$OUT/order_place.json" + fi + + order_id=$(jget "$confirm_file" order_id) + if [ -n "$order_id" ]; then + if curl -fksS --max-time 30 -X GET "$BASE/iserver/account/order/status/$order_id" -o "$OUT/order_status.json"; then + echo "captured order_status.json" + else + echo "WARNING: order_status fetch failed; continuing to cancel" + fi + if curl -fksS --max-time 30 -X GET "$BASE/iserver/account/orders" -o "$OUT/live_orders.json"; then + echo "captured live_orders.json" + else + echo "WARNING: live_orders fetch failed; continuing to cancel" + fi + if curl -fksS --max-time 30 -X DELETE "$BASE/iserver/account/$ACCOUNT/order/$order_id" -o "$OUT/order_cancel.json"; then + echo "captured order_cancel.json (cancelled order $order_id)" + else + echo "WARNING: cancel request failed — verify no resting order remains for $order_id" + fi + rm -f "$OUT/order_place.json" + else + echo "WARNING: no order_id parsed; skipping status/live/cancel. Inspect order_place/reply output." + fi +else + echo "skipping order write-path capture: pass an account id (arg 1 or IBKR_ACCOUNT)" +fi + echo "DONE. SANITIZE before committing: scrub account ids, balances, and names." diff --git a/docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md b/docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md index ef6205d..dab3b29 100644 --- a/docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md +++ b/docs/superpowers/plans/2026-07-11-ibkr-cpapi-writepath-wire.md @@ -744,11 +744,21 @@ print(d[0].get(sys.argv[2],"") if isinstance(d,list) and d else "")' "$1" "$2" order_id=$(jget "$confirm_file" order_id) if [ -n "$order_id" ]; then - fetch GET "/iserver/account/order/status/$order_id" order_status.json - fetch GET "/iserver/account/orders" live_orders.json - curl -fksS --max-time 30 -X DELETE "$BASE/iserver/account/$ACCOUNT/order/$order_id" \ - -o "$OUT/order_cancel.json" - echo "captured order_cancel.json (cancelled order $order_id)" + if curl -fksS --max-time 30 -X GET "$BASE/iserver/account/order/status/$order_id" -o "$OUT/order_status.json"; then + echo "captured order_status.json" + else + echo "WARNING: order_status fetch failed; continuing to cancel" + fi + if curl -fksS --max-time 30 -X GET "$BASE/iserver/account/orders" -o "$OUT/live_orders.json"; then + echo "captured live_orders.json" + else + echo "WARNING: live_orders fetch failed; continuing to cancel" + fi + if curl -fksS --max-time 30 -X DELETE "$BASE/iserver/account/$ACCOUNT/order/$order_id" -o "$OUT/order_cancel.json"; then + echo "captured order_cancel.json (cancelled order $order_id)" + else + echo "WARNING: cancel request failed — verify no resting order remains for $order_id" + fi rm -f "$OUT/order_place.json" else echo "WARNING: no order_id parsed; skipping status/live/cancel. Inspect order_place/reply output." From 0bb048edf31af47c7f68969ed9a2b32ac14ff805 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:14:37 +0000 Subject: [PATCH 08/13] test(ibkr): gated live place/confirm/cancel round-trip (#[ignore]) --- crates/adapter/ibkr/tests/live.rs | 69 ++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/adapter/ibkr/tests/live.rs b/crates/adapter/ibkr/tests/live.rs index 3901fef..a37b09c 100644 --- a/crates/adapter/ibkr/tests/live.rs +++ b/crates/adapter/ibkr/tests/live.rs @@ -10,7 +10,7 @@ //! is `https://localhost:5000/v1/api`). use std::process::Command; -use oath_adapter_ibkr::cpapi::{AuthStatus, decode}; +use oath_adapter_ibkr::cpapi::{AuthStatus, CancelResponse, OrderPlaceReply, decode}; #[test] #[ignore = "requires a live, authenticated Client Portal Gateway on https://localhost:5000"] @@ -35,3 +35,70 @@ fn live_auth_status_deserializes() { let _status: AuthStatus = decode(&output.stdout).expect("live auth/status should decode into AuthStatus"); } + +/// Drives a full place -> confirm -> cancel round-trip against a live gateway. +/// Places a far-below-market resting LIMIT BUY (will not fill), confirms any reply +/// warning, then cancels. Requires `IBKR_ACCOUNT` (paper). Override the contract with +/// `IBKR_CONID` (default AAPL 265598). +#[test] +#[ignore = "requires a live, authenticated Client Portal Gateway + IBKR_ACCOUNT (paper)"] +fn live_order_place_confirm_cancel_round_trip() { + let base = std::env::var("IBKR_GATEWAY") + .unwrap_or_else(|_| "https://localhost:5000/v1/api".to_owned()); + let account = std::env::var("IBKR_ACCOUNT").expect("set IBKR_ACCOUNT to a paper account id"); + let conid = std::env::var("IBKR_CONID").unwrap_or_else(|_| "265598".to_owned()); + + let curl = |args: &[&str]| -> Vec { + let output = Command::new("curl") + .args(["-fksS", "--max-time", "30"]) + .args(args) + .output() + .expect("curl should run"); + assert!(output.status.success(), "curl failed: {output:?}"); + output.stdout + }; + + // Place. + let body = format!( + r#"{{"orders":[{{"conid":{conid},"orderType":"LMT","side":"BUY","quantity":1,"tif":"DAY","price":1.00,"outsideRTH":false}}]}}"# + ); + let placed = curl(&[ + "-X", + "POST", + &format!("{base}/iserver/account/{account}/orders"), + "-H", + "Content-Type: application/json", + "-d", + &body, + ]); + let mut replies: Vec = decode(&placed).expect("place decodes"); + + // Confirm the reply chain until an order_id appears (bounded). + for _ in 0..5 { + let Some(reply_id) = replies.first().and_then(|r| r.id.clone()) else { + break; + }; + let confirmed = curl(&[ + "-X", + "POST", + &format!("{base}/iserver/reply/{reply_id}"), + "-H", + "Content-Type: application/json", + "-d", + r#"{"confirmed":true}"#, + ]); + replies = decode(&confirmed).expect("reply decodes"); + } + let order_id = replies + .first() + .and_then(|r| r.order_id.clone()) + .expect("a confirmed order_id"); + + // Cancel — always, so the round-trip leaves no resting order. + let cancelled = curl(&[ + "-X", + "DELETE", + &format!("{base}/iserver/account/{account}/order/{order_id}"), + ]); + let _resp: CancelResponse = decode(&cancelled).expect("cancel decodes"); +} From bee1bfe29f261564f5700bdbd6efa3e810b0fa66 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:28:52 +0000 Subject: [PATCH 09/13] docs(ibkr): whole-branch review polish (doc, tests, spec sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-approved polish across the cpapi write-path branch: - order.rs: drop stale "(added in later tasks)" from the module doc, since the response DTOs are now all present. - tests/order.rs: extend the two OrderPlaceReply union decode tests to assert the full opposite shape is absent, not just 4 of 6 fields. - spec §3.1: sync the OrderPlaceReply/CancelResponse snippet to the implementation's (correct) field spellings so Task 6 reconciliation starts from the right shape. --- crates/adapter/ibkr/src/cpapi/order.rs | 2 +- crates/adapter/ibkr/tests/order.rs | 2 ++ .../specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/adapter/ibkr/src/cpapi/order.rs b/crates/adapter/ibkr/src/cpapi/order.rs index c0eee7b..61e8af0 100644 --- a/crates/adapter/ibkr/src/cpapi/order.rs +++ b/crates/adapter/ibkr/src/cpapi/order.rs @@ -1,5 +1,5 @@ //! Order write-path wire layer: request bodies for placing and confirming orders, -//! plus the order-lifecycle response DTOs (added in later tasks). +//! plus the order-lifecycle response DTOs (place/reply union, cancel, status, live orders). //! //! Faithfully mirrors Client Portal API v1 JSON — no transport, no auth, no //! OATH-domain translation, no order-safety semantics. `side`, `orderType`, and diff --git a/crates/adapter/ibkr/tests/order.rs b/crates/adapter/ibkr/tests/order.rs index 28b0db0..4f22654 100644 --- a/crates/adapter/ibkr/tests/order.rs +++ b/crates/adapter/ibkr/tests/order.rs @@ -70,6 +70,7 @@ fn order_place_questions_decode_as_question_shape() { // Confirmation fields are absent on a question. assert!(q.order_id.is_none()); assert!(q.order_status.is_none()); + assert!(q.encrypt_message.is_none()); } #[test] @@ -84,6 +85,7 @@ fn order_reply_confirmed_decodes_as_confirmation_shape() { // Question fields are absent on a confirmation. assert!(c.id.is_none()); assert!(c.message.is_none()); + assert!(c.is_suppressed.is_none()); } #[test] diff --git a/docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md b/docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md index 25d0810..226cee1 100644 --- a/docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md +++ b/docs/superpowers/specs/2026-07-11-ibkr-cpapi-writepath-wire-design.md @@ -145,11 +145,11 @@ pub struct OrderPlaceReply { // "confirmation" shape (order accepted) pub order_id: Option, pub order_status: Option, - #[serde(rename = "encryptMessage")] pub encrypt_message: Option, + pub encrypt_message: Option, } pub struct CancelResponse { // fields reconciled from live capture - pub order_id: Option, pub msg: Option, + pub order_id: Option, pub msg: Option, pub conid: Option, pub account: Option, } pub struct OrderStatus { /* order_id, conid, side, order_status, size fields — from live capture */ } From 77fc70aa0f7a33ef42f8218e8020304d78938e81 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:55:07 +0000 Subject: [PATCH 10/13] test(ibkr): live round-trip uses request DTOs + cancel-on-drop guard --- crates/adapter/ibkr/tests/live.rs | 87 +++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/crates/adapter/ibkr/tests/live.rs b/crates/adapter/ibkr/tests/live.rs index a37b09c..ace08a1 100644 --- a/crates/adapter/ibkr/tests/live.rs +++ b/crates/adapter/ibkr/tests/live.rs @@ -10,7 +10,10 @@ //! is `https://localhost:5000/v1/api`). use std::process::Command; -use oath_adapter_ibkr::cpapi::{AuthStatus, CancelResponse, OrderPlaceReply, decode}; +use oath_adapter_ibkr::cpapi::{ + AuthStatus, CancelResponse, Endpoint, OrderPlaceReply, OrderRequest, PlaceOrderRequest, + ReplyConfirm, decode, +}; #[test] #[ignore = "requires a live, authenticated Client Portal Gateway on https://localhost:5000"] @@ -36,6 +39,35 @@ fn live_auth_status_deserializes() { decode(&output.stdout).expect("live auth/status should decode into AuthStatus"); } +/// Best-effort cancel-on-drop, so a panic between placing an order and the +/// explicit cancel cannot strand a resting paper order. Arm it (`order_id`) once +/// the order id is known; disarm it after a successful explicit cancel. +struct CancelGuard<'a> { + base: &'a str, + account: &'a str, + order_id: Option, +} + +impl Drop for CancelGuard<'_> { + fn drop(&mut self) { + if let Some(order_id) = &self.order_id { + let _ = Command::new("curl") + .args([ + "-fksS", + "--max-time", + "30", + "-X", + "DELETE", + &format!( + "{}/iserver/account/{}/order/{order_id}", + self.base, self.account + ), + ]) + .output(); + } + } +} + /// Drives a full place -> confirm -> cancel round-trip against a live gateway. /// Places a far-below-market resting LIMIT BUY (will not fill), confirms any reply /// warning, then cancels. Requires `IBKR_ACCOUNT` (paper). Override the contract with @@ -46,7 +78,10 @@ fn live_order_place_confirm_cancel_round_trip() { let base = std::env::var("IBKR_GATEWAY") .unwrap_or_else(|_| "https://localhost:5000/v1/api".to_owned()); let account = std::env::var("IBKR_ACCOUNT").expect("set IBKR_ACCOUNT to a paper account id"); - let conid = std::env::var("IBKR_CONID").unwrap_or_else(|_| "265598".to_owned()); + let conid: i64 = std::env::var("IBKR_CONID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(265_598); let curl = |args: &[&str]| -> Vec { let output = Command::new("curl") @@ -58,34 +93,55 @@ fn live_order_place_confirm_cancel_round_trip() { output.stdout }; - // Place. - let body = format!( - r#"{{"orders":[{{"conid":{conid},"orderType":"LMT","side":"BUY","quantity":1,"tif":"DAY","price":1.00,"outsideRTH":false}}]}}"# - ); + // Place a far-below-market resting LIMIT BUY (will not fill), built via the request DTOs. + let place_body = serde_json::to_string(&PlaceOrderRequest { + orders: vec![OrderRequest { + conid, + side: "BUY".to_owned(), + order_type: "LMT".to_owned(), + quantity: serde_json::Number::from(1_u64), + tif: "DAY".to_owned(), + price: Some(serde_json::Number::from(1_u64)), + aux_price: None, + coid: None, + outside_rth: Some(false), + }], + }) + .expect("place body serializes"); + let place_url = format!("{base}{}", Endpoint::place_orders(&account).path); let placed = curl(&[ "-X", "POST", - &format!("{base}/iserver/account/{account}/orders"), + &place_url, "-H", "Content-Type: application/json", "-d", - &body, + &place_body, ]); let mut replies: Vec = decode(&placed).expect("place decodes"); + let mut guard = CancelGuard { + base: &base, + account: &account, + order_id: None, + }; + // Confirm the reply chain until an order_id appears (bounded). + let reply_body = + serde_json::to_string(&ReplyConfirm { confirmed: true }).expect("reply body serializes"); for _ in 0..5 { let Some(reply_id) = replies.first().and_then(|r| r.id.clone()) else { break; }; + let reply_url = format!("{base}{}", Endpoint::reply(&reply_id).path); let confirmed = curl(&[ "-X", "POST", - &format!("{base}/iserver/reply/{reply_id}"), + &reply_url, "-H", "Content-Type: application/json", "-d", - r#"{"confirmed":true}"#, + &reply_body, ]); replies = decode(&confirmed).expect("reply decodes"); } @@ -93,12 +149,11 @@ fn live_order_place_confirm_cancel_round_trip() { .first() .and_then(|r| r.order_id.clone()) .expect("a confirmed order_id"); + guard.order_id = Some(order_id.clone()); - // Cancel — always, so the round-trip leaves no resting order. - let cancelled = curl(&[ - "-X", - "DELETE", - &format!("{base}/iserver/account/{account}/order/{order_id}"), - ]); + // Explicit cancel — decode the ack, then disarm the guard (order already cancelled). + let cancel_url = format!("{base}{}", Endpoint::cancel_order(&account, &order_id).path); + let cancelled = curl(&["-X", "DELETE", &cancel_url]); let _resp: CancelResponse = decode(&cancelled).expect("cancel decodes"); + guard.order_id = None; } From 8113f7094fc2e81dafaecddf8a1b5f64f74da530 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:58:01 +0000 Subject: [PATCH 11/13] fix(ibkr): reconcile order write-path to the live paper gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-captured (paper DUP…, sanitized) fixtures + DTO/capture.sh fixes from a real place→confirm→cancel dance. Live-wire truths: - place/reply confirmation `order_id` is a STRING; status/live/cancel `order_id` is an INTEGER (confirms the faithful-mirror split). - `order/status` limit price is `limit_price` (string), not `price`; sizes are strings ("1.0"); adds `account`. - `account/orders` price is a STRING ("1.00") not a number; `orderType` value is "Limit"; first call returns an EMPTY snapshot (warming) → capture.sh now polls. - a plain far-below-market LIMIT raises no reply warning (place → confirmation directly) → capture.sh saves that confirmation; questions fixture stays a documented representative. - cancel returns conid:-1, account:null. Gated live round-trip test passes against the real gateway. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/ibkr/src/cpapi/order.rs | 14 +++--- .../tests/fixtures/cpapi/live_orders.json | 38 +++++++++++++- .../tests/fixtures/cpapi/order_cancel.json | 7 ++- .../fixtures/cpapi/order_reply_confirmed.json | 8 ++- .../tests/fixtures/cpapi/order_status.json | 50 ++++++++++++++++++- crates/adapter/ibkr/tests/order.rs | 10 ++-- docker/cpapi/capture.sh | 21 ++++++-- 7 files changed, 130 insertions(+), 18 deletions(-) diff --git a/crates/adapter/ibkr/src/cpapi/order.rs b/crates/adapter/ibkr/src/cpapi/order.rs index 61e8af0..f806ff9 100644 --- a/crates/adapter/ibkr/src/cpapi/order.rs +++ b/crates/adapter/ibkr/src/cpapi/order.rs @@ -101,8 +101,8 @@ pub struct CancelResponse { /// Response of `GET /iserver/account/order/status/{order_id}`. /// -/// This endpoint is **`snake_case`**-native — no serde renames. Sizes arrive as strings -/// (kept faithfully as `String`); `price` is a bare `serde_json::Number`. +/// This endpoint is **`snake_case`**-native — no serde renames. Sizes and the limit +/// price arrive as strings (kept faithfully as `String`). #[derive(Debug, Clone, Deserialize)] pub struct OrderStatus { /// Order id (integer on this endpoint). @@ -121,10 +121,12 @@ pub struct OrderStatus { pub total_size: Option, /// Cumulative filled size, sent as a string. pub cum_fill: Option, - /// Limit/last price, when present. - pub price: Option, + /// Limit price, sent as a string (the wire field is `limit_price`), when present. + pub limit_price: Option, /// Time in force, when present. pub tif: Option, + /// Account id, when present. + pub account: Option, } /// Response of `GET /iserver/account/orders` — the account's live orders. @@ -162,6 +164,6 @@ pub struct LiveOrder { /// Total order size (`camelCase` `totalSize`), when present. #[serde(rename = "totalSize")] pub total_size: Option, - /// Limit/last price, when present. - pub price: Option, + /// Limit price, sent as a string on this endpoint, when present. + pub price: Option, } diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json b/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json index a39da26..762861a 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json @@ -1 +1,37 @@ -{"orders":[{"acct":"DU0000000","conid":265598,"orderId":1234567890,"ticker":"AAPL","side":"BUY","status":"PreSubmitted","orderType":"LIMIT","totalSize":1,"price":1.5}],"snapshot":false} +{ + "orders": [ + { + "acct": "DU0000000", + "conidex": "265598", + "conid": 265598, + "account": "DU0000000", + "orderId": 1234567890, + "cashCcy": "USD", + "sizeAndFills": "0/1", + "orderDesc": "Buy 1 AAPL Limit 1.00, Day", + "description1": "AAPL", + "ticker": "AAPL", + "secType": "STK", + "listingExchange": "NASDAQ.NMS", + "remainingQuantity": 1.0, + "filledQuantity": 0.0, + "totalSize": 1.0, + "companyName": "APPLE INC", + "status": "PreSubmitted", + "order_ccp_status": "Submitted", + "outsideRTH": false, + "origOrderType": "LIMIT", + "supportsTaxOpt": "1", + "lastExecutionTime": "260711115313", + "orderType": "Limit", + "bgColor": "#FFFFFF", + "fgColor": "#0000CC", + "isEventTrading": "0", + "price": "1.00", + "timeInForce": "CLOSE", + "lastExecutionTime_r": 1783770793000, + "side": "BUY" + } + ], + "snapshot": true +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json index 3640f70..2eadc61 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_cancel.json @@ -1 +1,6 @@ -{"order_id":1234567890,"msg":"Request was submitted","conid":265598,"account":"DU0000000"} +{ + "msg": "Request was submitted", + "order_id": 1234567890, + "conid": -1, + "account": null +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json index 556bdc7..7372796 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_reply_confirmed.json @@ -1 +1,7 @@ -[{"order_id":"1234567890","order_status":"PreSubmitted","encrypt_message":"1"}] +[ + { + "order_id": "1234567890", + "order_status": "PreSubmitted", + "encrypt_message": "1" + } +] diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json index bc1d25c..d18d7a1 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json @@ -1 +1,49 @@ -{"order_id":1234567890,"conid":265598,"symbol":"AAPL","side":"B","order_type":"Limit","order_status":"PreSubmitted","total_size":"1","cum_fill":"0","price":1.00,"tif":"DAY"} +{ + "sub_type": null, + "request_id": "8060282", + "server_id": "171078", + "order_id": 1234567890, + "conidex": "265598", + "conid": 265598, + "symbol": "AAPL", + "side": "B", + "contract_description_1": "AAPL", + "listing_exchange": "NASDAQ.NMS", + "option_acct": "c", + "company_name": "APPLE INC", + "size": "1.0", + "total_size": "1.0", + "currency": "USD", + "account": "DU0000000", + "order_type": "LIMIT", + "limit_price": "1.00", + "cum_fill": "0.0", + "order_status": "PreSubmitted", + "order_ccp_status": "0", + "order_status_description": "Order Submitted", + "tif": "DAY", + "fg_color": "#FFFFFF", + "bg_color": "#0000CC", + "order_not_editable": false, + "editable_fields": "\u001e", + "cannot_cancel_order": false, + "outside_rth": false, + "all_or_none": false, + "deactivate_order": false, + "use_price_mgmt_algo": false, + "sec_type": "STK", + "available_chart_periods": "#R|1", + "order_description": "Buy 1 Limit 1.00, Day", + "order_description_with_contract": "Buy 1 AAPL Limit 1.00, Day", + "clearing_id": "IB", + "clearing_name": "IB", + "alert_active": 1, + "child_order_type": "3", + "order_clearing_account": "DU0000000", + "size_and_fills": "0/1", + "exit_strategy_display_price": "1.00", + "exit_strategy_chart_description": "Buy 1 Limit 1.00, Day", + "exit_strategy_tool_availability": "1", + "allowed_duplicate_opposite": true, + "order_time": "260711115313" +} diff --git a/crates/adapter/ibkr/tests/order.rs b/crates/adapter/ibkr/tests/order.rs index 4f22654..fffc16b 100644 --- a/crates/adapter/ibkr/tests/order.rs +++ b/crates/adapter/ibkr/tests/order.rs @@ -104,8 +104,9 @@ fn order_status_decodes_snake_fields() { decode(include_bytes!("fixtures/cpapi/order_status.json")).expect("status decodes"); assert_eq!(status.order_id, Some(1_234_567_890)); assert_eq!(status.order_status.as_deref(), Some("PreSubmitted")); - // Sizes arrive as strings on this endpoint — kept faithfully as String. - assert_eq!(status.total_size.as_deref(), Some("1")); + // Sizes and the limit price arrive as strings on this endpoint — kept faithfully. + assert_eq!(status.total_size.as_deref(), Some("1.0")); + assert_eq!(status.limit_price.as_deref(), Some("1.00")); } #[test] @@ -113,9 +114,10 @@ fn live_orders_decode_camel_fields() { use oath_adapter_ibkr::cpapi::decode; let live: LiveOrders = decode(include_bytes!("fixtures/cpapi/live_orders.json")).expect("live orders decode"); - assert_eq!(live.snapshot, Some(false)); + assert_eq!(live.snapshot, Some(true)); let o = live.orders.first().expect("one live order"); assert_eq!(o.order_id, Some(1_234_567_890)); // renamed from "orderId" assert_eq!(o.ticker.as_deref(), Some("AAPL")); - assert_eq!(o.order_type.as_deref(), Some("LIMIT")); // renamed from "orderType" + assert_eq!(o.order_type.as_deref(), Some("Limit")); // renamed from "orderType" + assert_eq!(o.price.as_deref(), Some("1.00")); // string on this endpoint } diff --git a/docker/cpapi/capture.sh b/docker/cpapi/capture.sh index 505df9e..a7ba3d2 100755 --- a/docker/cpapi/capture.sh +++ b/docker/cpapi/capture.sh @@ -76,8 +76,9 @@ print(d[0].get(sys.argv[2],"") if isinstance(d,list) and d else "")' "$1" "$2" confirm_file="$OUT/order_reply_confirmed.json" else echo "no reply question was raised; order_place.json IS the confirmation." - echo " -> author order_place_questions.json as a documented representative fixture." - confirm_file="$OUT/order_place.json" + echo " -> keeping the representative order_place_questions.json (no live warning raised)." + cp "$OUT/order_place.json" "$OUT/order_reply_confirmed.json" + confirm_file="$OUT/order_reply_confirmed.json" fi order_id=$(jget "$confirm_file" order_id) @@ -87,10 +88,22 @@ print(d[0].get(sys.argv[2],"") if isinstance(d,list) and d else "")' "$1" "$2" else echo "WARNING: order_status fetch failed; continuing to cancel" fi - if curl -fksS --max-time 30 -X GET "$BASE/iserver/account/orders" -o "$OUT/live_orders.json"; then + # /iserver/account/orders returns an empty snapshot on the first call + # (snapshot warming); poll until it populates or the attempts run out. + live_ok="" + for _ in 1 2 3 4 5; do + if curl -fksS --max-time 30 -X GET "$BASE/iserver/account/orders" -o "$OUT/live_orders.json"; then + orders_n=$(python3 -c 'import json,sys +d=json.load(open(sys.argv[1])) +print(len(d.get("orders",[])) if isinstance(d,dict) else 0)' "$OUT/live_orders.json" 2>/dev/null || echo 0) + if [ "${orders_n:-0}" -gt 0 ]; then live_ok=1; break; fi + fi + sleep 1 + done + if [ -n "$live_ok" ]; then echo "captured live_orders.json" else - echo "WARNING: live_orders fetch failed; continuing to cancel" + echo "WARNING: live_orders still empty after retries (snapshot warming); continuing to cancel" fi if curl -fksS --max-time 30 -X DELETE "$BASE/iserver/account/$ACCOUNT/order/$order_id" -o "$OUT/order_cancel.json"; then echo "captured order_cancel.json (cancelled order $order_id)" From 167f703c6fff584d2b4d438530aea45b98ad0649 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:59:42 +0000 Subject: [PATCH 12/13] docs(ibkr): README + CHANGELOG for cpapi order write path --- CHANGELOG.md | 12 ++++++++++++ README.md | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 600b0fd..6f298f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`oath-adapter-ibkr` — CP API v1 order write-path wire layer.** Request-body serde + DTOs (`OrderRequest`/`PlaceOrderRequest`/`ReplyConfirm` — the crate's first serialize + direction), the order/reply union response (`OrderPlaceReply`, one all-optional struct), + and cancel/status/live-orders response DTOs (`CancelResponse`, `OrderStatus`, + `LiveOrders`/`LiveOrder`), plus `Endpoint` descriptors for place / reply-confirm / + cancel / order-status / live-orders (`Method::Delete` added). No transport, auth, or + OATH-domain translation (ADR-0022/0026 untouched). Fixtures are real, sanitized paper + captures via an extended `just ibkr-capture` order dance; a gated `#[ignore]` live test + drives a place → confirm → cancel round-trip. + ### Changed - **net-api compose polish — no behaviour change.** Added `#[must_use]` to diff --git a/README.md b/README.md index 13dcb0c..defc9d4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Every subsystem is defined behind a trait. Backends, adapters, transports, and s | `oath-adapter-net-api` | Transport-neutral composition primitives (`Layer`, `LayerBuilder`, `Stack`) + `ErrorKind` / `Timer` | | `oath-adapter-net-http-api` | HTTP transport contract (`Service`, …) over the `oath-adapter-net-api` kernel | | `oath-adapter-net-ws-api` | WebSocket transport contract (`Frame`, `WsSink`/`WsSource`, `Lifecycle`, `WsConnector`, …) over the `oath-adapter-net-api` kernel | -| `oath-adapter-ibkr` | IBKR venue adapter — Client Portal API v1 (`cpapi`) read-path wire layer; `webapi` (beta OAuth) / `tws` (socket) surfaces to follow | +| `oath-adapter-ibkr` | IBKR venue adapter — Client Portal API v1 (`cpapi`) read + order-write wire layer; `webapi` (beta OAuth) / `tws` (socket) surfaces to follow | | `oath-strategy-api` | User-facing `Strategy` trait and Signal ergonomics (the canonical `Signal` payload lives in `oath-model`, per ADR-0028) | | `oath-strategy-host` | Strategy Node binary: hosts user strategies, isolated from Core | | `oath-cli` | The first Frontend (MVP) | @@ -70,7 +70,7 @@ graph TD sup[oath-supervisor] --> model ``` -The crates above are compiling skeletons. Bus/Event-Log/persistence backends (e.g. `oath-bus-iceoryx2`, `oath-event-log-chronicle`, `oath-persistence-sqlite`) are coming soon. The first venue adapter, `oath-adapter-ibkr`, has begun with its Client Portal API v1 read-path wire layer. +The crates above are compiling skeletons. Bus/Event-Log/persistence backends (e.g. `oath-bus-iceoryx2`, `oath-event-log-chronicle`, `oath-persistence-sqlite`) are coming soon. The first venue adapter, `oath-adapter-ibkr`, covers the Client Portal API v1 read and order-write wire layers. ## Setup From 384e090fe18c5c46fcc4b126618c28f198bb8a9a Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:37:06 +0000 Subject: [PATCH 13/13] test(ibkr): final-review polish - arm the live-test CancelGuard as soon as an order id is known (initial confirmation or each confirmed reply), closing the question-flow window where a mid-round-trip panic could strand a resting order; - scrub ephemeral gateway ids/timestamps (request_id/server_id/order_time/ lastExecutionTime) from the order_status/live_orders fixtures for consistency with the account/order-id sanitization; - clarify the capture.sh no-question log line. Live round-trip re-verified against the paper gateway; no resting order. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json | 4 ++-- crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json | 6 +++--- crates/adapter/ibkr/tests/live.rs | 6 +++++- docker/cpapi/capture.sh | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json b/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json index 762861a..cc83ac3 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json @@ -22,14 +22,14 @@ "outsideRTH": false, "origOrderType": "LIMIT", "supportsTaxOpt": "1", - "lastExecutionTime": "260711115313", + "lastExecutionTime": "000000000000", "orderType": "Limit", "bgColor": "#FFFFFF", "fgColor": "#0000CC", "isEventTrading": "0", "price": "1.00", "timeInForce": "CLOSE", - "lastExecutionTime_r": 1783770793000, + "lastExecutionTime_r": 0, "side": "BUY" } ], diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json b/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json index d18d7a1..e134e6c 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/order_status.json @@ -1,7 +1,7 @@ { "sub_type": null, - "request_id": "8060282", - "server_id": "171078", + "request_id": "0", + "server_id": "0", "order_id": 1234567890, "conidex": "265598", "conid": 265598, @@ -45,5 +45,5 @@ "exit_strategy_chart_description": "Buy 1 Limit 1.00, Day", "exit_strategy_tool_availability": "1", "allowed_duplicate_opposite": true, - "order_time": "260711115313" + "order_time": "000000000000" } diff --git a/crates/adapter/ibkr/tests/live.rs b/crates/adapter/ibkr/tests/live.rs index ace08a1..b04fb38 100644 --- a/crates/adapter/ibkr/tests/live.rs +++ b/crates/adapter/ibkr/tests/live.rs @@ -120,10 +120,13 @@ fn live_order_place_confirm_cancel_round_trip() { ]); let mut replies: Vec = decode(&placed).expect("place decodes"); + // Arm the cancel guard the moment an order id is known — a plain place may confirm + // directly, otherwise each confirmed reply reveals it — so a panic mid-flow cannot + // strand a resting order. let mut guard = CancelGuard { base: &base, account: &account, - order_id: None, + order_id: replies.first().and_then(|r| r.order_id.clone()), }; // Confirm the reply chain until an order_id appears (bounded). @@ -144,6 +147,7 @@ fn live_order_place_confirm_cancel_round_trip() { &reply_body, ]); replies = decode(&confirmed).expect("reply decodes"); + guard.order_id = replies.first().and_then(|r| r.order_id.clone()); } let order_id = replies .first() diff --git a/docker/cpapi/capture.sh b/docker/cpapi/capture.sh index a7ba3d2..fc1e45b 100755 --- a/docker/cpapi/capture.sh +++ b/docker/cpapi/capture.sh @@ -76,7 +76,7 @@ print(d[0].get(sys.argv[2],"") if isinstance(d,list) and d else "")' "$1" "$2" confirm_file="$OUT/order_reply_confirmed.json" else echo "no reply question was raised; order_place.json IS the confirmation." - echo " -> keeping the representative order_place_questions.json (no live warning raised)." + echo " -> keeping the committed representative order_place_questions.json (no live warning raised)." cp "$OUT/order_place.json" "$OUT/order_reply_confirmed.json" confirm_file="$OUT/order_reply_confirmed.json" fi