From 6aa5b22c9a2f87d76943ad5225508eda1d8430d7 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:24:08 +0000 Subject: [PATCH 01/13] docs(ibkr): CPAPI read-path wire spec + implementation plan --- .../2026-07-10-ibkr-cpapi-readpath-wire.md | 1321 +++++++++++++++++ ...6-07-10-ibkr-cpapi-readpath-wire-design.md | 209 +++ 2 files changed, 1530 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md create mode 100644 docs/superpowers/specs/2026-07-10-ibkr-cpapi-readpath-wire-design.md diff --git a/docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md b/docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md new file mode 100644 index 0000000..12366ce --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md @@ -0,0 +1,1321 @@ +# IBKR CPAPI Read-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:** Build the first slice of the IBKR venue adapter — a transport-agnostic Client Portal API v1 (`cpapi`) **read-path** wire layer (serde DTOs + endpoint descriptors) with fixture tests, plus a hand-rolled paper-account gateway container for capturing those fixtures. + +**Architecture:** A new `oath-adapter-ibkr` crate whose `cpapi` module holds IBKR-internal serde DTOs that faithfully mirror CP API v1 read responses — no auth, no transport, no OATH-domain translation. Fixtures are captured from a hand-rolled Client Portal Gateway container (paper login) and drive TDD. Depends only on `serde`/`serde_json`/`thiserror`, so it is fully parallel to the in-flight `net-http` work and blocked by nothing. + +**Tech Stack:** Rust (edition 2024, MSRV 1.90), `serde`/`serde_json` derive, `thiserror`; Docker (`eclipse-temurin` JRE) for the gateway; `just` recipes; `curl` for capture/live test. + +**Spec:** [docs/superpowers/specs/2026-07-10-ibkr-cpapi-readpath-wire-design.md](../specs/2026-07-10-ibkr-cpapi-readpath-wire-design.md). Grounds: ADR-0003 (adapter-side translation), ADR-0025/0026 (deferred domain types), ADR-0023 (fixed-point money, deferred). + +## Global Constraints + +*Every task's requirements implicitly include this section.* + +- **Edition 2024, MSRV 1.90.** Validate with `just msrv` (`cargo +1.90 check --workspace --all-targets --all-features`). +- **`#![forbid(unsafe_code)]`** at crate root (workspace sets `unsafe_code = "deny"`; every crate's `lib.rs` forbids it — follow the pattern). +- **`just lint` runs `cargo clippy … -- -D warnings`.** Clippy `all` is deny; `pedantic`/`nursery`/`cargo` are `warn` but `-D warnings` promotes them to **hard errors**. Therefore, for every `pub` item and every `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 (`missing_errors_doc`). Do **not** derive `PartialEq` without `Eq` on an `Eq`-capable type (`derive_partial_eq_without_eq`). +- **`unwrap`/`expect`/`indexing` = warn in non-test code**, but **test code is exempt** (`.clippy.toml`: `allow-unwrap-in-tests`, `allow-expect-in-tests`, `allow-indexing-slicing-in-tests`). Non-test code returns `Result` / uses `.get()`. +- **Dependencies:** only `serde` (workspace dep — already carries `features = ["derive"]`), `serde_json` (workspace), `thiserror` (workspace). **No** `oath-model`, **no** `net-*-api`. `cargo-machete` runs in CI, so every declared dep must be used. +- **`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 even on private items. Cross-reference DTOs with **backticked names, not `[]` links**, to stay order-independent. Run `just doc` in every task's verification. +- **`just ci`** = `fmt fmt-toml typos lint check test deny doc machete gitleaks actionlint shellcheck`. New jargon → `_typos.toml`; shell scripts → shellcheck-clean (`set -euo pipefail`, quoted vars); **no secrets/credentials in committed files** (gitleaks); committed fixtures are **sanitized** (no account ids / balances / names). +- **Faithful-mirror rule (spec §7.4):** model each field as the wire actually sends it. Number-sent-as-string → `String`; ids/counts → `i64`; precision-sensitive numbers (money/quantity) → `serde_json::Number`; **no** `WireNum` enum unless a captured fixture proves a field arrives as both string and number; **no** OATH-domain translation (string→number parsing is deferred). +- **Workflow:** one issue, one PR. Isolate in a `.claude/worktrees/` git worktree — never switch the primary checkout's branch. Add a `CHANGELOG.md` `[Unreleased]` entry. `just ci` must pass before the PR. + +## File Structure + +``` +crates/adapter/ibkr/ + Cargo.toml # package manifest (serde/serde_json/thiserror) + src/ + lib.rs # crate docs + `#![forbid(unsafe_code)]` + `pub mod cpapi;` + cpapi/ + mod.rs # module docs + submodule decls + facade re-exports + endpoint.rs # `Method`, `Endpoint` (method + path template) + error.rs # `CpapiError` (error envelope), `WireError`, `decode` + auth.rs # `AuthStatus`, `ServerInfo`, `TickleResponse`, `TickleIServer` + portfolio.rs # `IServerAccounts`, `PortfolioAccount`, `Position` + secdef.rs # `SecdefSearchEntry`, `SecdefSection`, `SecdefInfo` + tests/ + endpoint.rs # path-rendering tests + error.rs # decode success/error/malformed tests + auth.rs # auth/tickle fixture tests + portfolio.rs # accounts/positions fixture tests + secdef.rs # secdef search/info fixture tests + live.rs # `#[ignore]` live-gateway test + fixtures/cpapi/*.json # captured (sanitized) response fixtures + +docker/cpapi/ + Dockerfile # hand-rolled Client Portal Gateway (JRE + clientportal.gw) + docker-compose.yml # one-command bring-up on :5000 + capture.sh # shellcheck-clean fixture capture + README.md # login + capture + sanitize + security notes +``` + +Root `Cargo.toml`, `_typos.toml`, `README.md`, `CHANGELOG.md`, `Justfile` are modified. + +--- + +### Task 1: Crate scaffold, workspace registration, jargon allowlist + +**Files:** +- Create: `crates/adapter/ibkr/Cargo.toml` +- Create: `crates/adapter/ibkr/src/lib.rs` +- Create: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Modify: `Cargo.toml` (workspace `members` + `workspace.dependencies`) +- Modify: `_typos.toml` (IBKR jargon) + +**Interfaces:** +- Produces: the `oath-adapter-ibkr` crate compiling as an empty `cpapi` module; later tasks add submodules under `src/cpapi/`. + +- [ ] **Step 1: Create the crate manifest** + +`crates/adapter/ibkr/Cargo.toml`: + +```toml +[package] +name = "oath-adapter-ibkr" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +``` + +- [ ] **Step 2: Create the crate root** + +`crates/adapter/ibkr/src/lib.rs`: + +```rust +//! IBKR venue adapter. +//! +//! Surface-neutral by design: the Client Portal API v1 wire layer lives under +//! [`cpapi`]. Future `webapi` (beta OAuth 2.0) and `tws` (socket) surfaces will be +//! siblings. This crate is the venue-side half of the ADR-0003 anti-corruption +//! boundary — it faithfully mirrors IBKR's wire and performs no translation to +//! OATH domain types (deferred until those types exist). +#![forbid(unsafe_code)] + +pub mod cpapi; +``` + +- [ ] **Step 3: Create the (empty) `cpapi` module** + +`crates/adapter/ibkr/src/cpapi/mod.rs`: + +```rust +//! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and +//! serde DTOs that mirror IBKR's JSON responses losslessly. No auth, no transport, +//! no OATH-domain translation. +``` + +- [ ] **Step 4: Register the crate in the workspace** + +In root `Cargo.toml`, add to `[workspace] members` immediately after the `"crates/adapter/api",` line: + +```toml + "crates/adapter/ibkr", +``` + +And in `[workspace.dependencies]`, immediately after the `oath-adapter-api = { … }` line: + +```toml +oath-adapter-ibkr = { path = "crates/adapter/ibkr", version = "0.1.0" } +``` + +- [ ] **Step 5: Add IBKR jargon to the typos allowlist** + +In `_typos.toml`, under `[default.extend-words]`, append (keep the existing `oath`/`strat` entries): + +```toml +# IBKR Client Portal API v1 jargon (see crates/adapter/ibkr). +cpapi = "cpapi" +iserver = "iserver" +secdef = "secdef" +conid = "conid" +ssodh = "ssodh" +acct = "acct" +mkt = "mkt" +hmds = "hmds" +``` + +- [ ] **Step 6: Verify it compiles, formats, and passes typos** + +Run: `cargo check -p oath-adapter-ibkr --locked` +Expected: compiles clean (no warnings). + +Run: `just fmt-toml && just typos && just doc` +Expected: all pass. (If `typos` flags another IBKR token, add it to `_typos.toml` and re-run.) + +- [ ] **Step 7: Commit** + +```bash +git add crates/adapter/ibkr/Cargo.toml crates/adapter/ibkr/src Cargo.toml Cargo.lock _typos.toml +git commit -m "feat(ibkr): scaffold oath-adapter-ibkr crate + cpapi module" +``` + +--- + +### Task 2: `Endpoint` + `Method` descriptors + +**Files:** +- Create: `crates/adapter/ibkr/src/cpapi/endpoint.rs` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Test: `crates/adapter/ibkr/tests/endpoint.rs` + +**Interfaces:** +- Produces: `Method { Get, Post }`; `Endpoint { method: Method, path: String }`; constructors `Endpoint::auth_status()`, `tickle()`, `iserver_accounts()`, `portfolio_accounts()`, `positions(account_id: &str, page: u32)`, `secdef_search()`, `secdef_info()`. Paths are relative to the gateway base `…/v1/api`. + +- [ ] **Step 1: Write the failing test** + +`crates/adapter/ibkr/tests/endpoint.rs`: + +```rust +//! Endpoint path-rendering tests. +use oath_adapter_ibkr::cpapi::{Endpoint, Method}; + +#[test] +fn positions_path_interpolates_account_and_page() { + let ep = Endpoint::positions("U1234567", 0); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/portfolio/U1234567/positions/0"); +} + +#[test] +fn tickle_is_a_post_to_slash_tickle() { + let ep = Endpoint::tickle(); + assert_eq!(ep.method, Method::Post); + assert_eq!(ep.path, "/tickle"); +} + +#[test] +fn secdef_search_is_a_post() { + assert_eq!(Endpoint::secdef_search().method, Method::Post); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p oath-adapter-ibkr --test endpoint` +Expected: FAIL — `cannot find … Endpoint`/`Method` in `oath_adapter_ibkr::cpapi`. + +- [ ] **Step 3: Implement `endpoint.rs`** + +`crates/adapter/ibkr/src/cpapi/endpoint.rs`: + +```rust +//! Endpoint descriptors for the Client Portal API v1 read 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 +//! transport; a future HTTP binding turns an `Endpoint` into a request. + +/// HTTP method for a Client Portal API v1 [`Endpoint`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Method { + /// HTTP `GET`. + Get, + /// HTTP `POST`. + Post, +} + +/// A Client Portal API v1 endpoint: an HTTP [`Method`] and a path relative to the +/// `/v1/api` base (for example `/portfolio/accounts`). +#[derive(Debug, Clone)] +pub struct Endpoint { + /// The HTTP method. + pub method: Method, + /// The path, relative to the `/v1/api` base. + pub path: String, +} + +impl Endpoint { + /// `GET /iserver/auth/status` — current authentication / brokerage-session status. + #[must_use] + pub fn auth_status() -> Self { + Self { method: Method::Get, path: "/iserver/auth/status".to_owned() } + } + + /// `POST /tickle` — session keepalive; also relays the `iserver` auth status. + #[must_use] + pub fn tickle() -> Self { + Self { method: Method::Post, path: "/tickle".to_owned() } + } + + /// `GET /iserver/accounts` — accounts the user can trade. + #[must_use] + pub fn iserver_accounts() -> Self { + Self { method: Method::Get, path: "/iserver/accounts".to_owned() } + } + + /// `GET /portfolio/accounts` — accounts for portfolio/position queries; must be + /// called before other `/portfolio` endpoints. + #[must_use] + pub fn portfolio_accounts() -> Self { + Self { method: Method::Get, path: "/portfolio/accounts".to_owned() } + } + + /// `GET /portfolio/{account_id}/positions/{page}` — one page of positions. + #[must_use] + pub fn positions(account_id: &str, page: u32) -> Self { + Self { + method: Method::Get, + path: format!("/portfolio/{account_id}/positions/{page}"), + } + } + + /// `POST /iserver/secdef/search` — contract search by symbol / company name. + #[must_use] + pub fn secdef_search() -> Self { + Self { method: Method::Post, path: "/iserver/secdef/search".to_owned() } + } + + /// `GET /iserver/secdef/info` — contract details (call after `secdef_search`). + #[must_use] + pub fn secdef_info() -> Self { + Self { method: Method::Get, path: "/iserver/secdef/info".to_owned() } + } +} +``` + +- [ ] **Step 4: Wire the module + re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, append below the `//!` header: + +```rust + +pub mod endpoint; + +pub use endpoint::{Endpoint, Method}; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cargo test -p oath-adapter-ibkr --test endpoint` +Expected: PASS (3 tests). + +- [ ] **Step 6: Lint + doc** + +Run: `just lint && just doc` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/adapter/ibkr/src/cpapi tests +git commit -m "feat(ibkr): cpapi Endpoint + Method descriptors" +``` + +--- + +### Task 3: `CpapiError` envelope, `WireError`, `decode` + +**Files:** +- Create: `crates/adapter/ibkr/src/cpapi/error.rs` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Test: `crates/adapter/ibkr/tests/error.rs` + +**Interfaces:** +- Produces: `CpapiError { error: String, status_code: Option }`; `enum WireError { Json(serde_json::Error) }`; `fn decode(bytes: &[u8]) -> Result`. `decode` is the single entry point later tasks (and the future transport) use to turn a response body into a typed value. + +- [ ] **Step 1: Write the failing test** + +`crates/adapter/ibkr/tests/error.rs`: + +```rust +//! Tests for the CP API v1 error envelope and the `decode` entry point. +use oath_adapter_ibkr::cpapi::{decode, CpapiError, WireError}; + +#[test] +fn error_envelope_decodes() { + let bytes = br#"{"error":"no bridge","statusCode":401}"#; + let err: CpapiError = decode(bytes).expect("error envelope should decode"); + assert_eq!(err.error, "no bridge"); + assert_eq!(err.status_code, Some(401)); +} + +#[test] +fn error_envelope_without_status_code_decodes() { + let bytes = br#"{"error":"Please query /accounts first"}"#; + let err: CpapiError = decode(bytes).expect("bare error should decode"); + assert_eq!(err.status_code, None); +} + +#[test] +fn malformed_json_is_a_wire_error() { + let bytes = b"not json"; + let result: Result = decode(bytes); + assert!(matches!(result, Err(WireError::Json(_)))); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p oath-adapter-ibkr --test error` +Expected: FAIL — `CpapiError`/`WireError`/`decode` not found. + +- [ ] **Step 3: Implement `error.rs`** + +`crates/adapter/ibkr/src/cpapi/error.rs`: + +```rust +//! The Client Portal API v1 error envelope, this crate's decode error type, and the +//! [`decode`] entry point for turning a response body into a typed value. + +use serde::Deserialize; +use serde::de::DeserializeOwned; +use thiserror::Error; + +/// The JSON error body IBKR returns for a failed Client Portal API v1 request, +/// for example `{"error":"no bridge","statusCode":401}`. +#[derive(Debug, Clone, Deserialize)] +pub struct CpapiError { + /// Human-readable error message. + pub error: String, + /// HTTP-style status code, when present. + #[serde(rename = "statusCode")] + pub status_code: Option, +} + +/// An error decoding a Client Portal API v1 response body. +#[derive(Debug, Error)] +pub enum WireError { + /// The body was not valid JSON for the target type. + #[error("malformed Client Portal API JSON: {0}")] + Json(#[from] serde_json::Error), +} + +/// Deserialize a Client Portal API v1 response body into `T`. +/// +/// The wire layer carries no transport, so the caller (a future HTTP binding) +/// decides — from the HTTP status — whether to `decode::` a success body or +/// `decode::` an error body. +/// +/// # Errors +/// +/// Returns [`WireError::Json`] if `bytes` is not valid JSON for `T`. +pub fn decode(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice(bytes)?) +} +``` + +- [ ] **Step 4: Wire the module + re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, add `pub mod error;` (below `pub mod endpoint;`) and extend the re-exports: + +```rust +pub use error::{decode, CpapiError, WireError}; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cargo test -p oath-adapter-ibkr --test error` +Expected: PASS (3 tests). + +- [ ] **Step 6: Lint + doc** + +Run: `just lint && just doc` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/adapter/ibkr/src/cpapi tests +git commit -m "feat(ibkr): cpapi error envelope + decode entry point" +``` + +--- + +### Task 4: Auth/session DTOs (`AuthStatus`, `TickleResponse`) + +**Files:** +- Create: `crates/adapter/ibkr/src/cpapi/auth.rs` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Test: `crates/adapter/ibkr/tests/auth.rs` + +**Interfaces:** +- Consumes: `decode` (Task 3). +- Produces: `AuthStatus`, `ServerInfo`, `TickleResponse`, `TickleIServer`. + +> These fixtures are **representative** (documented shapes); Task 9 replaces them with real sanitized paper-gateway captures and the DTOs are reconciled to the real fields then. Model fields as the wire sends them (spec §7.4). + +- [ ] **Step 1: Create the fixtures** + +`crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json`: + +```json +{"authenticated":true,"competing":false,"connected":true,"message":"","MAC":"00:00:00:00:00:00","serverInfo":{"serverName":"JifN00000","serverVersion":"Build 10.25.0"},"fail":""} +``` + +`crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json`: + +```json +{"session":"0000000000000000","ssoExpires":600000,"collision":false,"userId":100000000,"hmds":{"error":"no bridge"},"iserver":{"authStatus":{"authenticated":true,"competing":false,"connected":true,"message":"","MAC":"00:00:00:00:00:00","serverInfo":{"serverName":"JifN00000","serverVersion":"Build 10.25.0"},"fail":""}}} +``` + +- [ ] **Step 2: Write the failing test** + +`crates/adapter/ibkr/tests/auth.rs`: + +```rust +//! Fixture tests for the auth/session DTOs. +use oath_adapter_ibkr::cpapi::{decode, AuthStatus, TickleResponse}; + +#[test] +fn auth_status_deserializes() { + let status: AuthStatus = + decode(include_bytes!("fixtures/cpapi/auth_status.json")).expect("auth_status decodes"); + assert!(status.authenticated); + assert!(status.connected); + assert!(!status.competing); +} + +#[test] +fn tickle_relays_iserver_auth_status() { + let tickle: TickleResponse = + decode(include_bytes!("fixtures/cpapi/tickle.json")).expect("tickle decodes"); + assert!(!tickle.session.is_empty()); + let iserver = tickle.iserver.expect("tickle relays the iserver block"); + assert!(iserver.auth_status.authenticated); +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cargo test -p oath-adapter-ibkr --test auth` +Expected: FAIL — `AuthStatus`/`TickleResponse` not found. + +- [ ] **Step 4: Implement `auth.rs`** + +`crates/adapter/ibkr/src/cpapi/auth.rs`: + +```rust +//! Session/auth read endpoints: `iserver/auth/status` and `tickle`. + +use serde::Deserialize; + +/// Server identity block embedded in an [`AuthStatus`]. +#[derive(Debug, Clone, Deserialize)] +pub struct ServerInfo { + /// Server name. + #[serde(rename = "serverName")] + pub server_name: Option, + /// Server version string. + #[serde(rename = "serverVersion")] + pub server_version: Option, +} + +/// Response of `GET|POST /iserver/auth/status` — the brokerage-session state. +#[derive(Debug, Clone, Deserialize)] +pub struct AuthStatus { + /// `true` once initial authentication passes. + pub authenticated: bool, + /// `true` when another session is competing for the same account. + pub competing: bool, + /// `true` when connected to the brokerage backend. + pub connected: bool, + /// Optional status message. + #[serde(default)] + pub message: String, + /// Machine access code, when present. + #[serde(rename = "MAC")] + pub mac: Option, + /// Failure reason; empty when healthy. + #[serde(default)] + pub fail: String, + /// Server identity, when present. + #[serde(rename = "serverInfo")] + pub server_info: Option, +} + +/// The `iserver` block of a [`TickleResponse`], wrapping the auth status. +#[derive(Debug, Clone, Deserialize)] +pub struct TickleIServer { + /// The embedded auth status. + #[serde(rename = "authStatus")] + pub auth_status: AuthStatus, +} + +/// Response of `POST /tickle` — session keepalive; also relays the auth status. +#[derive(Debug, Clone, Deserialize)] +pub struct TickleResponse { + /// Opaque session token. + pub session: String, + /// SSO expiry, in seconds, when present. + #[serde(rename = "ssoExpires")] + pub sso_expires: Option, + /// `true` when a session collision occurred. + #[serde(default)] + pub collision: bool, + /// Numeric user id, when present. + #[serde(rename = "userId")] + pub user_id: Option, + /// The relayed `iserver` auth block, when present. + pub iserver: Option, +} +``` + +- [ ] **Step 5: Wire the module + re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, add `pub mod auth;` and: + +```rust +pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse}; +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cargo test -p oath-adapter-ibkr --test auth` +Expected: PASS (2 tests). + +- [ ] **Step 7: Lint + doc, then commit** + +Run: `just lint && just doc` +Expected: clean. + +```bash +git add crates/adapter/ibkr +git commit -m "feat(ibkr): cpapi auth/tickle DTOs + fixtures" +``` + +--- + +### Task 5: Account DTOs (`IServerAccounts`, `PortfolioAccount`) + +**Files:** +- Create: `crates/adapter/ibkr/src/cpapi/portfolio.rs` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Test: `crates/adapter/ibkr/tests/portfolio.rs` + +**Interfaces:** +- Consumes: `decode` (Task 3). +- Produces: `IServerAccounts`, `PortfolioAccount`. (`Position` is added in Task 6, same file.) + +- [ ] **Step 1: Create the fixtures** + +`crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json`: + +```json +{"accounts":["DU0000000"],"selectedAccount":"DU0000000","isPaper":true} +``` + +`crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json`: + +```json +[{"id":"DU0000000","accountId":"DU0000000","currency":"USD","type":"DEMO","displayName":"Paper"}] +``` + +- [ ] **Step 2: Write the failing test** + +`crates/adapter/ibkr/tests/portfolio.rs`: + +```rust +//! Fixture tests for the portfolio DTOs. +use oath_adapter_ibkr::cpapi::{decode, IServerAccounts, PortfolioAccount}; + +#[test] +fn iserver_accounts_deserializes() { + let accts: IServerAccounts = decode(include_bytes!("fixtures/cpapi/iserver_accounts.json")) + .expect("iserver accounts decodes"); + assert_eq!(accts.accounts, vec!["DU0000000".to_owned()]); + assert_eq!(accts.selected_account.as_deref(), Some("DU0000000")); +} + +#[test] +fn portfolio_accounts_deserializes() { + let accts: Vec = + decode(include_bytes!("fixtures/cpapi/portfolio_accounts.json")) + .expect("portfolio accounts decodes"); + assert_eq!(accts.len(), 1); + let first = accts.first().expect("one account"); + assert_eq!(first.id, "DU0000000"); + assert_eq!(first.account_type.as_deref(), Some("DEMO")); +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cargo test -p oath-adapter-ibkr --test portfolio` +Expected: FAIL — `IServerAccounts`/`PortfolioAccount` not found. + +- [ ] **Step 4: Implement `portfolio.rs` (accounts)** + +`crates/adapter/ibkr/src/cpapi/portfolio.rs`: + +```rust +//! Portfolio read endpoints: `iserver/accounts`, `portfolio/accounts`, and +//! `portfolio/{account}/positions/{page}`. + +use serde::Deserialize; + +/// Response of `GET /iserver/accounts` — accounts the user can trade. +#[derive(Debug, Clone, Deserialize)] +pub struct IServerAccounts { + /// Tradable account ids. + pub accounts: Vec, + /// The currently selected account, when present. + #[serde(rename = "selectedAccount")] + pub selected_account: Option, +} + +/// One element of `GET /portfolio/accounts` — an account for portfolio queries. +#[derive(Debug, Clone, Deserialize)] +pub struct PortfolioAccount { + /// Account id (for example `"DU0000000"`). + pub id: String, + /// Account id (a duplicate field IBKR also returns), when present. + #[serde(rename = "accountId")] + pub account_id: Option, + /// Base currency, when present. + pub currency: Option, + /// Account type — IBKR's `type` field (`"DEMO"` for paper), when present. + #[serde(rename = "type")] + pub account_type: Option, +} +``` + +- [ ] **Step 5: Wire the module + re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, add `pub mod portfolio;` and: + +```rust +pub use portfolio::{IServerAccounts, PortfolioAccount}; +``` + +- [ ] **Step 6: Run test, lint, doc, commit** + +Run: `cargo test -p oath-adapter-ibkr --test portfolio` → PASS (2 tests). +Run: `just lint && just doc` → clean. + +```bash +git add crates/adapter/ibkr +git commit -m "feat(ibkr): cpapi account DTOs + fixtures" +``` + +--- + +### Task 6: `Position` DTO (illustrates the faithful-mirror numeric rule) + +**Files:** +- Modify: `crates/adapter/ibkr/src/cpapi/portfolio.rs` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/positions.json` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Modify: `crates/adapter/ibkr/tests/portfolio.rs` + +**Interfaces:** +- Produces: `Position` — `conid: i64`; monetary/quantity fields as `serde_json::Number` (precision preserved; no `f64`, no parse — spec §7.4). + +- [ ] **Step 1: Create the fixture** + +`crates/adapter/ibkr/tests/fixtures/cpapi/positions.json`: + +```json +[{"acctId":"DU0000000","conid":265598,"contractDesc":"AAPL","position":100,"mktPrice":150.25,"mktValue":15025.0,"currency":"USD","assetClass":"STK"}] +``` + +- [ ] **Step 2: Add the failing test** + +Append to `crates/adapter/ibkr/tests/portfolio.rs`: + +```rust +#[test] +fn positions_deserialize_conid_as_int_and_money_as_number() { + use oath_adapter_ibkr::cpapi::Position; + let positions: Vec = + decode(include_bytes!("fixtures/cpapi/positions.json")).expect("positions decode"); + let p = positions.first().expect("one position"); + assert_eq!(p.conid, 265_598); + // Money stays a serde_json::Number — faithful to the wire, no premature f64. + assert_eq!(p.mkt_price.as_ref().map(ToString::to_string).as_deref(), Some("150.25")); +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cargo test -p oath-adapter-ibkr --test portfolio positions_deserialize` +Expected: FAIL — `Position` not found. + +- [ ] **Step 4: Implement `Position` in `portfolio.rs`** + +Append to `crates/adapter/ibkr/src/cpapi/portfolio.rs`: + +```rust +/// One element of `GET /portfolio/{account}/positions/{page}`. +/// +/// `conid` is an **integer** on this endpoint (contrast `secdef/search`, where the +/// same logical id arrives as a *string* — see `SecdefSearchEntry`). Monetary and +/// quantity fields are kept as `serde_json::Number`: faithful to the wire, precision +/// preserved, no premature `f64`. Conversion to fixed-point (ADR-0023) is the future +/// translation layer's job, not the wire's. +#[derive(Debug, Clone, Deserialize)] +pub struct Position { + /// Account id owning the position, when present. + #[serde(rename = "acctId")] + pub acct_id: Option, + /// IBKR contract id (integer on this endpoint). + pub conid: i64, + /// Signed position size, when present. + pub position: Option, + /// Market price, when present. + #[serde(rename = "mktPrice")] + pub mkt_price: Option, + /// Market value, when present. + #[serde(rename = "mktValue")] + pub mkt_value: Option, + /// Position currency, when present. + pub currency: Option, + /// Contract description, when present. + #[serde(rename = "contractDesc")] + pub contract_desc: Option, +} +``` + +- [ ] **Step 5: Extend the re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, extend the portfolio re-export to include `Position`: + +```rust +pub use portfolio::{IServerAccounts, PortfolioAccount, Position}; +``` + +- [ ] **Step 6: Run test, lint, doc, commit** + +Run: `cargo test -p oath-adapter-ibkr --test portfolio` → PASS (3 tests). +Run: `just lint && just doc` → clean. + +```bash +git add crates/adapter/ibkr +git commit -m "feat(ibkr): cpapi Position DTO (conid i64, money as Number)" +``` + +--- + +### Task 7: Secdef DTOs (`SecdefSearchEntry`, `SecdefInfo`) + +**Files:** +- Create: `crates/adapter/ibkr/src/cpapi/secdef.rs` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json` +- Create: `crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json` +- Modify: `crates/adapter/ibkr/src/cpapi/mod.rs` +- Test: `crates/adapter/ibkr/tests/secdef.rs` + +**Interfaces:** +- Produces: `SecdefSearchEntry` (`conid: String`!), `SecdefSection`, `SecdefInfo` (`conid: i64`). The string-vs-int `conid` split across these two endpoints is the concrete payoff of the faithful-mirror rule. + +- [ ] **Step 1: Create the fixtures** + +`crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json`: + +```json +[{"conid":"265598","companyName":"APPLE INC","symbol":"AAPL","description":"NASDAQ","sections":[{"secType":"STK"},{"secType":"OPT","months":"JAN26;FEB26"}]}] +``` + +`crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json`: + +```json +[{"conid":265598,"symbol":"AAPL","secType":"STK","exchange":"NASDAQ","currency":"USD","companyName":"APPLE INC"}] +``` + +- [ ] **Step 2: Write the failing test** + +`crates/adapter/ibkr/tests/secdef.rs`: + +```rust +//! Fixture tests for the secdef DTOs. Note the deliberate conid type split: +//! secdef/search sends conid as a string; secdef/info sends it as an integer. +use oath_adapter_ibkr::cpapi::{decode, SecdefInfo, SecdefSearchEntry}; + +#[test] +fn secdef_search_conid_is_a_string() { + let entries: Vec = + decode(include_bytes!("fixtures/cpapi/secdef_search.json")).expect("search decodes"); + let e = entries.first().expect("one entry"); + assert_eq!(e.conid, "265598"); + assert_eq!(e.sections.len(), 2); +} + +#[test] +fn secdef_info_conid_is_an_int() { + let infos: Vec = + decode(include_bytes!("fixtures/cpapi/secdef_info.json")).expect("info decodes"); + let i = infos.first().expect("one info"); + assert_eq!(i.conid, 265_598); + assert_eq!(i.sec_type.as_deref(), Some("STK")); +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cargo test -p oath-adapter-ibkr --test secdef` +Expected: FAIL — `SecdefSearchEntry`/`SecdefInfo` not found. + +- [ ] **Step 4: Implement `secdef.rs`** + +`crates/adapter/ibkr/src/cpapi/secdef.rs`: + +```rust +//! Contract search/info read endpoints: `iserver/secdef/search` and +//! `iserver/secdef/info`. + +use serde::Deserialize; + +/// A tradable section within a [`SecdefSearchEntry`] (for example `STK`, `OPT`). +#[derive(Debug, Clone, Deserialize)] +pub struct SecdefSection { + /// Security type, for example `"STK"`, `"OPT"`. + #[serde(rename = "secType")] + pub sec_type: String, + /// Available expiry months (`OPT`/`FUT`), when present. + pub months: Option, +} + +/// One element of `POST /iserver/secdef/search`. +/// +/// `conid` is a **string** on this endpoint — the same logical id is an integer on +/// the positions and `secdef/info` endpoints. Modelling each as the wire actually +/// sends it (not a forced shared type) is the faithful-mirror rule (spec §7.4). +#[derive(Debug, Clone, Deserialize)] +pub struct SecdefSearchEntry { + /// IBKR contract id (a string on this endpoint). + pub conid: String, + /// Company name, when present. + #[serde(rename = "companyName")] + pub company_name: Option, + /// Symbol, when present. + pub symbol: Option, + /// Free-text description (often the exchange), when present. + pub description: Option, + /// Tradable sections by security type. + #[serde(default)] + pub sections: Vec, +} + +/// One element of `GET /iserver/secdef/info`. +/// +/// `conid` is an **integer** here (contrast [`SecdefSearchEntry`]). +#[derive(Debug, Clone, Deserialize)] +pub struct SecdefInfo { + /// IBKR contract id (an integer on this endpoint). + pub conid: i64, + /// Symbol, when present. + pub symbol: Option, + /// Security type, when present. + #[serde(rename = "secType")] + pub sec_type: Option, + /// Primary exchange, when present. + pub exchange: Option, + /// Contract currency, when present. + pub currency: Option, + /// Company name, when present. + #[serde(rename = "companyName")] + pub company_name: Option, +} +``` + +- [ ] **Step 5: Wire the module + re-exports** + +In `crates/adapter/ibkr/src/cpapi/mod.rs`, add `pub mod secdef;` and: + +```rust +pub use secdef::{SecdefInfo, SecdefSearchEntry, SecdefSection}; +``` + +- [ ] **Step 6: Run test, lint, doc, commit** + +Run: `cargo test -p oath-adapter-ibkr --test secdef` → PASS (2 tests). +Run: `just lint && just doc` → clean. + +```bash +git add crates/adapter/ibkr +git commit -m "feat(ibkr): cpapi secdef DTOs (conid string vs int)" +``` + +--- + +### Task 8: Hand-rolled paper gateway harness + +**Files:** +- Create: `docker/cpapi/Dockerfile` +- Create: `docker/cpapi/docker-compose.yml` +- Create: `docker/cpapi/capture.sh` +- Create: `docker/cpapi/README.md` +- Modify: `Justfile` (add `ibkr-capture` recipe) + +**Interfaces:** +- Produces: a buildable Client Portal Gateway container, a `just ibkr-capture [account]` recipe, and documentation for logging in + capturing fixtures. This is empirical infra — its deliverable is verified by building the image and reaching the login page, not by a unit test. + +- [ ] **Step 1: Write the Dockerfile** + +`docker/cpapi/Dockerfile`: + +```dockerfile +# Hand-rolled IBKR Client Portal Gateway (CP API v1). The gateway is a plain Java +# web server — no Xvfb/VNC/IBC (that machinery is only for the TWS desktop app). +FROM eclipse-temurin:21-jre + +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/clientportal + +# Download + unpack the gateway distribution (extracts bin/, root/, dist/, ...). +ADD https://download2.interactivebrokers.com/portal/clientportal.gw.zip clientportal.gw.zip +RUN unzip -q clientportal.gw.zip && rm clientportal.gw.zip + +EXPOSE 5000 +# Uses the distribution's shipped root/conf.yaml (listens on :5000). +CMD ["bin/run.sh", "root/conf.yaml"] +``` + +- [ ] **Step 2: Write the compose file** + +`docker/cpapi/docker-compose.yml`: + +```yaml +services: + cpgw: + build: . + image: oath-cpapi-gw + container_name: oath-cpapi-gw + ports: + - "5000:5000" + restart: unless-stopped + # If the browser login is blocked (403 / "not allowed"), the gateway's shipped + # root/conf.yaml ips.allow is too strict for container networking. Copy the + # shipped root/conf.yaml out, widen ips.allow for local dev, and bind-mount it: + # volumes: + # - ./conf.override.yaml:/opt/clientportal/root/conf.yaml:ro +``` + +- [ ] **Step 3: Write the capture script (shellcheck-clean)** + +`docker/cpapi/capture.sh`: + +```bash +#!/usr/bin/env bash +# Capture Client Portal API v1 read-path responses from a running, authenticated +# gateway into the crate fixture directory. Log in first at https://localhost:5000. +# Usage: docker/cpapi/capture.sh [ACCOUNT_ID] (or set IBKR_ACCOUNT) +set -euo pipefail + +BASE="${IBKR_GATEWAY:-https://localhost:5000/v1/api}" +OUT="crates/adapter/ibkr/tests/fixtures/cpapi" +ACCOUNT="${1:-${IBKR_ACCOUNT:-}}" +mkdir -p "$OUT" + +fetch() { + # $1 = method, $2 = path (relative to BASE), $3 = output filename + curl -ksS -X "$1" "$BASE$2" -o "$OUT/$3" + echo "captured $3" +} + +fetch GET /iserver/auth/status auth_status.json +fetch POST /tickle tickle.json +fetch GET /iserver/accounts iserver_accounts.json +fetch GET /portfolio/accounts portfolio_accounts.json + +if [ -n "$ACCOUNT" ]; then + fetch GET "/portfolio/$ACCOUNT/positions/0" positions.json +else + echo "skipping positions.json: pass an account id (arg 1 or IBKR_ACCOUNT)" +fi + +curl -ksS -X POST "$BASE/iserver/secdef/search" \ + -H 'Content-Type: application/json' \ + -d '{"symbol":"AAPL","name":false,"secType":"STK"}' \ + -o "$OUT/secdef_search.json" +echo "captured secdef_search.json" + +fetch GET "/iserver/secdef/info?conid=265598&secType=STK" secdef_info.json + +echo "DONE. SANITIZE before committing: scrub account ids, balances, and names." +``` + +- [ ] **Step 4: Write the harness README** + +`docker/cpapi/README.md`: + +````markdown +# IBKR Client Portal Gateway (paper) — fixture harness + +A hand-rolled container that runs IBKR's Client Portal API **v1** gateway so we can +log in with a **paper** account and capture read-path responses as test fixtures. +The gateway is a plain Java web server — no Xvfb/VNC/IBC. + +## Prerequisites +- Docker + Docker Compose. +- An IBKR **paper** account with API access enabled. (2FA on the paper login makes + the manual browser step harder; disable it on the paper user if possible.) + +## Run + authenticate +```bash +docker compose -f docker/cpapi/docker-compose.yml up -d --build +# open https://localhost:5000 in a browser, accept the self-signed cert, +# and log in with your PAPER credentials. Leave the tab; the session lives here. +``` +The brokerage session times out after ~5 min idle; `/tickle` keeps it alive. +If the login page rejects you (403 / "not allowed"), the shipped `root/conf.yaml` +`ips.allow` is too strict for container networking — see the commented bind-mount in +`docker-compose.yml`. + +## Capture fixtures +```bash +just ibkr-capture DU0000000 # your paper account id +``` +This writes raw JSON to `crates/adapter/ibkr/tests/fixtures/cpapi/`. + +## 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. +Keep `conid`s (public reference data). `gitleaks` runs in CI — no secrets. +```` + +- [ ] **Step 5: Make the script executable + add the `just` recipe** + +```bash +chmod +x docker/cpapi/capture.sh +``` + +In `Justfile`, add near the other recipes: + +```make +# Capture Client Portal API v1 read-path fixtures from a running, authenticated +# gateway (see docker/cpapi/README.md). Pass a paper account id. +ibkr-capture account="": + docker/cpapi/capture.sh {{account}} +``` + +- [ ] **Step 6: Verify the harness** + +Run: `docker compose -f docker/cpapi/docker-compose.yml build` +Expected: image builds successfully. + +Run: `shellcheck docker/cpapi/capture.sh` +Expected: no findings. + +Run: `just --list | grep ibkr-capture` +Expected: the recipe is listed. + +Manual (empirical): `docker compose -f docker/cpapi/docker-compose.yml up -d` then open `https://localhost:5000` — the login page loads. (If blocked, apply the `conf.override.yaml` bind-mount from Step 2.) + +- [ ] **Step 7: Commit** + +```bash +git add docker/cpapi Justfile +git commit -m "feat(ibkr): hand-rolled Client Portal Gateway harness + capture recipe" +``` + +--- + +### Task 9: Capture & commit real paper-account fixtures + +**Files:** +- Modify: `crates/adapter/ibkr/tests/fixtures/cpapi/*.json` (replace representative with real, sanitized) +- Modify (as needed): `crates/adapter/ibkr/src/cpapi/{auth,portfolio,secdef}.rs` to reconcile with real fields + +**Interfaces:** +- Consumes: the harness (Task 8) and the DTOs (Tasks 4–7). +- Produces: **real, sanitized** fixtures that the existing fixture tests pass against — satisfying the spec's "real captured fixtures" DoD. + +> **Human-gated:** needs the paper account. Do this before merging; it replaces the representative fixtures from Tasks 4–7 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`). + +- [ ] **Step 2: Capture** + +Run: `just ibkr-capture ` +Expected: the seven `*.json` files under `tests/fixtures/cpapi/` are overwritten with live responses. + +- [ ] **Step 3: Sanitize** + +Edit each fixture: replace account ids with `DU0000000`, zero balances/P&L/quantities, remove names. Keep `conid`s. + +Run (verify JSON is still valid): `for f in crates/adapter/ibkr/tests/fixtures/cpapi/*.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` +Expected: PASS. If a test fails because a real field differs from the representative shape, adjust the DTO in `src/cpapi/*.rs` (rename via `#[serde(rename)]`, make a field `Option`, or fix a type per the wire) and the assertion, honoring the faithful-mirror rule. Re-run until green. + +- [ ] **Step 5: Guard against leaked account ids** + +Run: `git grep -nE 'U[0-9]{7}' -- crates/adapter/ibkr/tests/fixtures || echo "clean"` +Expected: `clean` (only the `DU0000000` placeholder remains, which does not match). + +- [ ] **Step 6: Lint, doc, commit** + +Run: `just lint && just doc` → clean. + +```bash +git add crates/adapter/ibkr +git commit -m "test(ibkr): real sanitized paper-gateway fixtures + DTO reconcile" +``` + +--- + +### Task 10: Gated live integration test + +**Files:** +- Create: `crates/adapter/ibkr/tests/live.rs` + +**Interfaces:** +- Consumes: `decode`, `AuthStatus`. Shells `curl -k` at the running gateway. `#[ignore]`d so it stays out of `just ci` (which runs `--all-features`, so a cargo feature would not exclude it). + +- [ ] **Step 1: Write the ignored live test** + +`crates/adapter/ibkr/tests/live.rs`: + +```rust +//! Live integration test against a running, authenticated Client Portal Gateway. +//! +//! `#[ignore]` keeps it out of `just ci` — `just test` runs `--all-features`, so a +//! cargo feature would NOT exclude it, but nextest/cargo test skip ignored tests. +//! Run it explicitly (gateway up + logged in at https://localhost:5000): +//! cargo test -p oath-adapter-ibkr --test live -- --ignored +//! # or: cargo nextest run -p oath-adapter-ibkr --run-ignored +use std::process::Command; + +use oath_adapter_ibkr::cpapi::{decode, AuthStatus}; + +#[test] +#[ignore = "requires a live, authenticated Client Portal Gateway on https://localhost:5000"] +fn live_auth_status_deserializes() { + let base = std::env::var("IBKR_GATEWAY") + .unwrap_or_else(|_| "https://localhost:5000/v1/api".to_owned()); + let output = Command::new("curl") + .args(["-ksS", "-X", "GET", &format!("{base}/iserver/auth/status")]) + .output() + .expect("curl should run"); + assert!(output.status.success(), "curl failed: {output:?}"); + // Decoding is the assertion; `authenticated` depends on live login state. + let _status: AuthStatus = + decode(&output.stdout).expect("live auth/status should decode into AuthStatus"); +} +``` + +- [ ] **Step 2: Verify it compiles and is skipped by default** + +Run: `cargo test -p oath-adapter-ibkr --test live` +Expected: compiles; `1 test, 0 passed, 1 ignored` (skipped). + +Run: `just lint` +Expected: clean. + +- [ ] **Step 3: (Optional, manual) run it against the live gateway** + +With the gateway up + logged in: `cargo test -p oath-adapter-ibkr --test live -- --ignored` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add crates/adapter/ibkr/tests/live.rs +git commit -m "test(ibkr): gated live-gateway integration test (#[ignore])" +``` + +--- + +### Task 11: README, CHANGELOG, and full CI gate + +**Files:** +- Modify: `README.md` (crate table, dependency graph, "coming soon" line) +- Modify: `CHANGELOG.md` (`[Unreleased]`) + +**Interfaces:** +- Produces: the finished PR — docs updated and `just ci` green. + +- [ ] **Step 1: Add the crate table row** + +In `README.md`, add after the `oath-adapter-net-ws-api` row: + +```markdown +| `oath-adapter-ibkr` | IBKR venue adapter — Client Portal API v1 (`cpapi`) read-path wire layer; `webapi` (beta OAuth) / `tws` (socket) surfaces to follow | +``` + +- [ ] **Step 2: Add the dependency-graph node** + +In the `mermaid` graph in `README.md`, add a standalone node near the other adapter nodes (the crate depends on no internal crate — only `serde`): + +``` + ibkr[oath-adapter-ibkr] +``` + +- [ ] **Step 3: Update the "coming soon" line** + +Replace the venue-adapter clause of the closing paragraph: + +```markdown +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. +``` + +- [ ] **Step 4: Add the CHANGELOG entry** + +In `CHANGELOG.md`, insert an `### Added` section immediately under `## [Unreleased]` (above the existing `### Changed`): + +```markdown +### Added + +- **`oath-adapter-ibkr` (new crate) — IBKR Client Portal API v1 read-path wire layer.** + Transport-agnostic serde DTOs for the CP API v1 read endpoints (`iserver/auth/status`, + `tickle`, `iserver/accounts`, `portfolio/accounts`, `portfolio/{acct}/positions`, + `iserver/secdef/search` / `info`), an `Endpoint` descriptor, and a `decode` entry point. + Depends only on `serde`/`serde_json`/`thiserror`; no OATH-domain translation yet + (deferred until `InstrumentId`/`Order` land, per ADR-0003/0025/0026). Ships a + hand-rolled Client Portal Gateway container (`docker/cpapi/`) and a `just ibkr-capture` + recipe for paper-account fixtures. Web API (beta OAuth 2.0) and TWS (socket) are future + sibling modules. +``` + +- [ ] **Step 5: 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 6: Commit** + +```bash +git add README.md CHANGELOG.md +git commit -m "docs(ibkr): README crate row + graph node + CHANGELOG entry" +``` + +- [ ] **Step 7: Open the PR** + +Push the branch and open a PR that references the issue (`Closes #N`), summarizing the slice: CP API v1 read-path wire layer + paper gateway harness; no domain translation yet. + +--- + +## Self-Review + +**Spec coverage:** +- §2 read endpoints (auth/status, tickle, iserver/accounts, portfolio/accounts, positions, secdef search/info) → Tasks 2, 4–7. ✅ +- §3.1 wire module (`Endpoint`, `CpapiError`, narrow DTO surface, no transport, `cpapi` namespace) → Tasks 1–7. ✅ +- §3.2 hand-rolled gateway harness + `just ibkr-capture` + sanitized fixtures → Tasks 8, 9. ✅ +- §4 fixture-driven TDD + `#[ignore]` live test + `just doc` per task → Tasks 4–7, 9, 10. ✅ +- §5 workspace/lint conformance + README update → Global Constraints + Task 11. ✅ +- §6 DoD (crate + tests in `just ci`, harness, gated live test, README, CHANGELOG, `just ci` green) → Task 11. ✅ +- §7.4 numeric faithful-mirror (String / `i64` / `serde_json::Number`; no `WireNum`) → Tasks 6, 7. ✅ +- §7.5 Web API deferred, not pre-coupled → out of scope by construction (no `webapi` code). ✅ + +**Placeholder scan:** every step ships concrete file content, an exact command, and expected output. Representative fixtures in Tasks 4–7 are explicitly flagged as reconciled to reality in Task 9 (not a placeholder — a deliberate representative-then-real flow). + +**Type consistency:** `decode` (Task 3) is the single deserialization entry used verbatim in Tasks 4–7, 10. Re-export names in `cpapi/mod.rs` accrue monotonically and match each module's `pub` items. `conid` is `String` in `SecdefSearchEntry` and `i64` in `Position`/`SecdefInfo` — intentional and asserted. `Method`/`Endpoint` derive only what tests compare (`Method: PartialEq + Eq`); DTOs derive `Debug, Clone, Deserialize` only (no `PartialEq` → no `derive_partial_eq_without_eq`). diff --git a/docs/superpowers/specs/2026-07-10-ibkr-cpapi-readpath-wire-design.md b/docs/superpowers/specs/2026-07-10-ibkr-cpapi-readpath-wire-design.md new file mode 100644 index 0000000..01c97df --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-ibkr-cpapi-readpath-wire-design.md @@ -0,0 +1,209 @@ +# IBKR CPAPI read-path wire layer + paper gateway harness (2026-07-10) + +The **first slice of the IBKR venue adapter**: a transport-agnostic, IBKR-internal +**wire layer** for the Client Portal Web API (CPAPI) **REST read** endpoints, plus a +**hand-rolled** containerized Client Portal Gateway that authenticates a **paper** +account so we can capture real response JSON as test fixtures. + +- **Status:** design — awaiting review, then implementation plan. +- **Scope:** one issue, one PR (wire crate + gateway harness). +- **Parallelism:** genuinely independent of the in-flight `net-http` hardening — the + `wire` module depends on **`serde`/`serde_json` only** (no `oath-model`, no + `net-*-api`), so nothing in flight can block or invalidate it. This is *why* this + slice was chosen as the parallel track. + +## 1. Context & motivation + +OATH needs venue adapters; IBKR is the first ([README](../../../README.md) lists +`oath-adapter-ibkr` as "coming soon"). Today **no `oath-adapter-ibkr` crate exists**. + +The adapter's real foundations are still empty skeletons: + +- [`oath-adapter-api`](../../../crates/adapter/api/src/lib.rs) is a 3-line skeleton — + there is **no `Broker` trait, no `DataProvider` trait** anywhere in the tree. +- [`oath-model`](../../../crates/model/src/) has only `error`, `price`, `quantity`, + `side` — **no `InstrumentId`, `Instrument`, `Order`, or `OrderId`**. ADR-0025 and + ADR-0026 define these on paper; they are unbuilt. + +So any adapter work that touches the **OATH side** of the translation boundary is +blocked on building those foundations first (the contract-first path, deliberately +set aside for now). The one slice that is simultaneously (1) IBKR-specific, +(2) parallel to `net-http`, and (3) free of unbuilt OATH types is the **venue-side +half of [ADR-0003](../../../docs/adr/0003-canonical-model-adapter-translation.md)'s +anti-corruption boundary**: model the CPAPI wire, and defer *all* OATH-domain +translation until the domain types exist. + +**API-surface decision: target Client Portal API v1 (`cpapi`) now; Web API (beta) and +TWS deferred.** The adapter is **surface-pluggable** (see §3.1). We build against **Client +Portal API v1** — IBKR's *current, stable, GA* REST surface, reached through the local +Client Portal Gateway (`https://localhost:5000/v1/api`, session + `/tickle`) — which +reuses the existing `net-http` stack. Two other surfaces are deliberately deferred as +separate future modules: + +- **IBKR Web API** — IBKR's newer unified surface (`https://api.ibkr.com/v1/api`, + OAuth 2.0 `private_key_jwt`). It is explicitly *"in beta and subject to change."* For the + read path it documents *the same backend resources / JSON shapes* as CP API v1 — the real + differences are **auth + base URL** plus additive `/gw/api`, `/oauth`, `/oauth2` + families. Because it is beta and, for our read path, differs only in auth/transport, we + do **not** couple to it now (see §7.5). +- **TWS / IB Gateway** — a proprietary length-prefixed TCP socket protocol needing a + raw-TCP transport OATH lacks; a genuinely different wire. + +This slice covers the CP API v1 **REST read path only**; order writes and WS streaming are +deferred (see §2). *(Surface landscape verified against IBKR docs 2026-07-10, medium +confidence — the beta Web API reference pages were not directly reachable, so the +"same read-path wire" claim is documented, not independently verified.)* + +## 2. Scope + +**In:** + +- `wire` DTOs + (de)serialization for the read endpoints, plus the CPAPI error + envelope(s): + - `GET /iserver/auth/status` — session auth status + - `POST /tickle` — session keepalive (+ session sub-object) + - `GET /iserver/accounts` and `GET /portfolio/accounts` — tradable / portfolio accounts + - `GET /portfolio/{accountId}/positions/{pageId}` — positions + - `POST /iserver/secdef/search` — contract search by symbol/company + - `GET /iserver/secdef/info` — contract details (after search) +- An **endpoint descriptor** (HTTP method + path template with typed params) per endpoint. +- **Fixture-based unit tests** that run in `just ci`. +- A **hand-rolled** Client Portal Gateway container + a capture workflow + a **gated** + live integration test. + +**Out (deferred on purpose):** + +- 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. +- **WS** streaming envelopes and market-data snapshot — need a `net-ws` backend that + does not exist yet. +- **Any translation to OATH domain types** — blocked on unbuilt `InstrumentId` / `Order`. +- The `Broker` / `DataProvider` trait impls. +- **Credential automation** — login is a manual browser SSO step (see §3.2). + +## 3. Architecture + +Two components: a Rust `wire` module (the parallel code) and a container harness (the +fixture source). + +### 3.1 `oath-adapter-ibkr` crate — `wire` module (deep module) + +- **New crate** at `crates/adapter/ibkr`, package `oath-adapter-ibkr`; added to + `[workspace] members` and `[workspace.dependencies]`. +- **Dependencies:** `serde` (derive), `serde_json`, `thiserror` (for the error type). + **No `oath-model`, no `net-*-api`.** Dev-deps: `serde_json` for fixtures (+ optional + `pretty_assertions`). +- **Narrow public surface** — one typed struct per response: `AuthStatus`, + `TickleResponse`, `Account`, `Position`, `SecdefSearchEntry`, `SecdefInfo` — plus a + `CpapiError` envelope and an `Endpoint` descriptor (method enum + path template with + typed params: `account_id`, `page_id`, `conid`). +- **Represents CPAPI's JSON faithfully** with idiomatic Rust: renamed fields + (`#[serde(rename)]`), optional/missing fields (`Option`), numbers-sent-as-strings kept + as `String`, string-or-number *polymorphic* fields via a tolerant `WireNum` enum **only + if a captured fixture proves the field is polymorphic**, and ambiguous error shapes via + an untagged enum. The layer mirrors the wire **losslessly + and interprets no values** — string→number parsing and the mapping onto OATH's model + (the anti-corruption *translation*) are the deferred second half (§2). +- **No transport.** The module maps `serde_json` ⇄ typed values; the future adapter + feeds it response bytes from `net-http-hyper`. This keeps it a pure, offline-testable + deep module — the property that makes it parallelizable. +- **Surface-neutral crate, per-surface modules.** The package name `oath-adapter-ibkr` + is deliberately surface-agnostic. The Client Portal API v1 wire lives under a top-level + `cpapi` module. Two future siblings are anticipated: `webapi` (IBKR's beta OAuth-2.0 + surface) and `tws` (the binary socket protocol). `tws` shares nothing with `cpapi` + (a different wire); `webapi` documents the *same* read-path JSON as `cpapi` but a + different auth/transport — so *if* it is built, whether to share the read-path DTOs is a + decision for that time (§7.5), not now. +- **Module layout:** `cpapi/mod.rs` (re-exports, `Endpoint`, `CpapiError`), + `cpapi/auth.rs`, `cpapi/portfolio.rs`, `cpapi/secdef.rs`. (Split out `cpapi::wire` + later only if per-surface translation code joins.) Scale files to size. + +### 3.2 Paper gateway harness (`docker/cpapi/`) + +- **Hand-rolled Dockerfile** on a minimal JRE base (e.g. `eclipse-temurin:21-jre`; + floor is Java 8u192+): download `clientportal.gw.zip` from IBKR + (`https://download2.interactivebrokers.com/portal/clientportal.gw.zip`), unzip, copy + our `root/conf.yaml`, expose `5000`, entrypoint `bin/run.sh root/conf.yaml`. +- The Client Portal Gateway is a **pure Java web server** — **no Xvfb/VNC/IBC** (that + machinery is only for the TWS *desktop* app). The container stays lightweight. +- **No credentials baked in.** Authentication is a **manual one-time browser login** at + `https://localhost:5000` with paper creds. No secret handling in the harness. +- `docker-compose.yml` for one-command bring-up; a `README` with the login + capture steps. +- **Session:** CPAPI brokerage sessions time out after **5 min idle**; `/tickle` ~every + 60 s. Capture and the live test run within a session window after manual login. A + standing keepalive loop is **out of scope** for this slice (noted for later). +- **Fixture capture:** a `just ibkr-capture` recipe (`curl -k` against + `https://localhost:5000/v1/api/…`) writes raw JSON to + `crates/adapter/ibkr/tests/fixtures/cpapi/*.json`, followed by a documented **sanitization** + pass. Only sanitized fixtures are committed. + +## 4. Testing strategy (TDD, fixture-driven) + +- **Red → green per endpoint:** write a test that deserializes + `tests/fixtures/cpapi/.json` into the target DTO and asserts key fields → fails + (type absent) → define the DTO → green. +- Fixtures are **real, sanitized paper-gateway responses** (captured via §3.2), so the + tests encode *actual* CPAPI behaviour, not doc guesses. +- Round-trip (serialize) only where we also send a body; the read path is mostly `GET` + (plus trivial-body `POST`s), so most tests assert deserialize. +- **Live integration test:** marked **`#[ignore]`** (needs a live, authenticated gateway). + A cargo feature would *not* work — `just test` runs `--all-features`, which would switch + the feature on in CI; `nextest`/`cargo test` skip `#[ignore]` tests by default, so + `#[ignore]` keeps it out of `just ci` regardless. It shells `curl -k` at the running + gateway and deserializes the live response with our DTOs. Run explicitly with + `--run-ignored` (nextest) or `-- --ignored` (cargo test). Documented how to run. +- **CI:** only fixture-based unit tests run in `just ci`, so the DoD stays green offline. + Include `just doc` in per-task verification (broken intra-doc links pass check/lint/test). + +## 5. Workspace / lint conformance + +- Compiles under `[workspace.lints]`: no `unsafe` (forbidden), **no `unwrap`/`expect`/ + indexing** in non-test code (custom serde helpers return `Result` + `thiserror`), + `missing_docs` satisfied, edition 2024 / MSRV 1.90. +- `cargo-deny` / `typos` / `cargo-machete` clean. New deps (`serde`, `serde_json`) go in + `[workspace.dependencies]`. +- Update the [README](../../../README.md) crate table + dependency-graph note for + `oath-adapter-ibkr` (drop / update the "coming soon" example line). + +## 6. Deliverables / Definition of done + +- `crates/adapter/ibkr/` with the `cpapi` wire module + fixture tests passing in `just ci`. +- `docker/cpapi/` hand-rolled gateway (Dockerfile, compose, `conf.yaml`, README) that + brings up an authenticated paper session; `just ibkr-capture` recipe. +- Gated live test present, excluded from CI. +- README updated; `CHANGELOG.md` `[Unreleased]` entry added (kept per project convention). +- `just ci` green. + +## 7. Decisions (settled 2026-07-10) + +1. **ADR — yes (short).** Record the surface choice: the IBKR adapter targets **Client + Portal API v1 (`cpapi`)** — the current, stable, GA surface — with **IBKR Web API** + (beta, OAuth 2.0) and **TWS** (socket) as deferred future surfaces, *not excluded*. + Frame it as "cpapi first," not "cpapi instead of." References ADR-0003. The wire DTOs + themselves need no ADR. +2. **Single crate + per-surface modules.** One `oath-adapter-ibkr` crate; CP API v1 under + a `cpapi` module, with `webapi` and `tws` as possible future siblings. Revisit a + dedicated crate only if a real boundary need appears (YAGNI). +3. **Fixture scrub:** strip account ids / balances / names (PII); keep `conid`s (public + reference data). Confirm the exact field set against real captured JSON at capture time. +4. **Numeric handling — faithful mirror, no parse in the wire.** Wire structs represent + what arrives *losslessly*: a number sent as a JSON string stays `String` (parsing it to + a number is *interpretation* — i.e. translation, deferred per §2). Consistent numbers + use `i64` (ids/counts) or `serde_json::Number` (precision-sensitive bare numbers). Each + field is modelled as exactly what its captured fixture shows. **String-or-number + *polymorphic* fields are not pre-modelled** — only if a captured fixture actually shows + the same field arriving as both a string and a number do we introduce a tolerant + `enum WireNum { Str(String), Num(serde_json::Number) }` for that field (acceptance, not + conversion). All string→domain-number conversion (money → fixed-point + per ADR-0023) lives in the later translation layer. `#[serde(rename)]` and `Option` + remain in use — those are lossless *structural* choices, not value interpretation. +5. **Web API (beta) — deferred, not pre-coupled.** IBKR's unified Web API is *"in beta and + subject to change"* and, for our read path, differs from CP API v1 only in **auth + base + URL** (OAuth 2.0 at `api.ibkr.com` vs gateway session at `localhost:5000`) — the + response JSON is documented as the *same* backend resources. We therefore do **not** + pre-build a shared wire for it (YAGNI; do not couple to an unstable beta). When/if a + `webapi` module is actually built, decide *then* — against the beta as it stands, and + after directly verifying its reference (unreachable this pass) — whether to extract the + shared read-path DTOs or duplicate. Naming note: IBKR titles even the CP API v1 docs + "Web API v1.0," so the future module's name should make the **auth/transport** + distinction explicit (that, not the payload schema, is the real seam). From b71fb2dcc35469aeec88faa6075ba0cc4ae2bd06 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:35:44 +0000 Subject: [PATCH 02/13] feat(ibkr): scaffold oath-adapter-ibkr crate + cpapi module --- Cargo.lock | 9 +++++++++ Cargo.toml | 2 ++ _typos.toml | 9 +++++++++ crates/adapter/ibkr/Cargo.toml | 19 +++++++++++++++++++ crates/adapter/ibkr/src/cpapi/mod.rs | 3 +++ crates/adapter/ibkr/src/lib.rs | 10 ++++++++++ 6 files changed, 52 insertions(+) create mode 100644 crates/adapter/ibkr/Cargo.toml create mode 100644 crates/adapter/ibkr/src/cpapi/mod.rs create mode 100644 crates/adapter/ibkr/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 56560aa..d403ac6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -548,6 +548,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "oath-adapter-ibkr" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "oath-adapter-net-api" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 21444ce..c9ff363 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "crates/core/host", "crates/strategy/api", "crates/adapter/api", + "crates/adapter/ibkr", "crates/strategy/host", "crates/cli", "crates/supervisor", @@ -60,6 +61,7 @@ oath-core-portfolio = { path = "crates/core/portfolio", version = "0.1.0" } oath-core-risk = { path = "crates/core/risk", version = "0.1.0" } oath-strategy-api = { path = "crates/strategy/api", version = "0.1.0" } oath-adapter-api = { path = "crates/adapter/api", version = "0.1.0" } +oath-adapter-ibkr = { path = "crates/adapter/ibkr", version = "0.1.0" } oath-core-api = { path = "crates/core/api", version = "0.1.0" } oath-core-kernel = { path = "crates/core/kernel", version = "0.1.0" } diff --git a/_typos.toml b/_typos.toml index 4c945da..e698c1f 100644 --- a/_typos.toml +++ b/_typos.toml @@ -14,3 +14,12 @@ extend-exclude = ["Cargo.lock", "target/"] oath = "oath" # Mermaid diagram node abbreviations used in README.md architecture diagrams. strat = "strat" +# IBKR Client Portal API v1 jargon (see crates/adapter/ibkr). +cpapi = "cpapi" +iserver = "iserver" +secdef = "secdef" +conid = "conid" +ssodh = "ssodh" +acct = "acct" +mkt = "mkt" +hmds = "hmds" diff --git a/crates/adapter/ibkr/Cargo.toml b/crates/adapter/ibkr/Cargo.toml new file mode 100644 index 0000000..7453481 --- /dev/null +++ b/crates/adapter/ibkr/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "oath-adapter-ibkr" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +# cargo-machete: deps declared ahead of first use; prune from `ignored` as they +# are adopted (serde/serde_json/thiserror land in the cpapi wire/error modules). +[package.metadata.cargo-machete] +ignored = ["serde", "serde_json", "thiserror"] diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs new file mode 100644 index 0000000..73d8b65 --- /dev/null +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -0,0 +1,3 @@ +//! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and +//! serde DTOs that mirror IBKR's JSON responses losslessly. No auth, no transport, +//! no OATH-domain translation. diff --git a/crates/adapter/ibkr/src/lib.rs b/crates/adapter/ibkr/src/lib.rs new file mode 100644 index 0000000..31db6f1 --- /dev/null +++ b/crates/adapter/ibkr/src/lib.rs @@ -0,0 +1,10 @@ +//! IBKR venue adapter. +//! +//! Surface-neutral by design: the Client Portal API v1 wire layer lives under +//! [`cpapi`]. Future `webapi` (beta OAuth 2.0) and `tws` (socket) surfaces will be +//! siblings. This crate is the venue-side half of the ADR-0003 anti-corruption +//! boundary — it faithfully mirrors IBKR's wire and performs no translation to +//! OATH domain types (deferred until those types exist). +#![forbid(unsafe_code)] + +pub mod cpapi; From 94b3dba210ed2853a13ca644c8d96b2023e79627 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:46:22 +0000 Subject: [PATCH 03/13] feat(ibkr): cpapi Endpoint + Method descriptors --- crates/adapter/ibkr/src/cpapi/endpoint.rs | 90 +++++++++++++++++++++++ crates/adapter/ibkr/src/cpapi/mod.rs | 4 + crates/adapter/ibkr/tests/endpoint.rs | 21 ++++++ 3 files changed, 115 insertions(+) create mode 100644 crates/adapter/ibkr/src/cpapi/endpoint.rs create mode 100644 crates/adapter/ibkr/tests/endpoint.rs diff --git a/crates/adapter/ibkr/src/cpapi/endpoint.rs b/crates/adapter/ibkr/src/cpapi/endpoint.rs new file mode 100644 index 0000000..d71a9af --- /dev/null +++ b/crates/adapter/ibkr/src/cpapi/endpoint.rs @@ -0,0 +1,90 @@ +//! Endpoint descriptors for the Client Portal API v1 read 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 +//! transport; a future HTTP binding turns an `Endpoint` into a request. + +/// HTTP method for a Client Portal API v1 [`Endpoint`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Method { + /// HTTP `GET`. + Get, + /// HTTP `POST`. + Post, +} + +/// A Client Portal API v1 endpoint: an HTTP [`Method`] and a path relative to the +/// `/v1/api` base (for example `/portfolio/accounts`). +#[derive(Debug, Clone)] +pub struct Endpoint { + /// The HTTP method. + pub method: Method, + /// The path, relative to the `/v1/api` base. + pub path: String, +} + +impl Endpoint { + /// `GET /iserver/auth/status` — current authentication / brokerage-session status. + #[must_use] + pub fn auth_status() -> Self { + Self { + method: Method::Get, + path: "/iserver/auth/status".to_owned(), + } + } + + /// `POST /tickle` — session keepalive; also relays the `iserver` auth status. + #[must_use] + pub fn tickle() -> Self { + Self { + method: Method::Post, + path: "/tickle".to_owned(), + } + } + + /// `GET /iserver/accounts` — accounts the user can trade. + #[must_use] + pub fn iserver_accounts() -> Self { + Self { + method: Method::Get, + path: "/iserver/accounts".to_owned(), + } + } + + /// `GET /portfolio/accounts` — accounts for portfolio/position queries; must be + /// called before other `/portfolio` endpoints. + #[must_use] + pub fn portfolio_accounts() -> Self { + Self { + method: Method::Get, + path: "/portfolio/accounts".to_owned(), + } + } + + /// `GET /portfolio/{account_id}/positions/{page}` — one page of positions. + #[must_use] + pub fn positions(account_id: &str, page: u32) -> Self { + Self { + method: Method::Get, + path: format!("/portfolio/{account_id}/positions/{page}"), + } + } + + /// `POST /iserver/secdef/search` — contract search by symbol / company name. + #[must_use] + pub fn secdef_search() -> Self { + Self { + method: Method::Post, + path: "/iserver/secdef/search".to_owned(), + } + } + + /// `GET /iserver/secdef/info` — contract details (call after `secdef_search`). + #[must_use] + pub fn secdef_info() -> Self { + Self { + method: Method::Get, + path: "/iserver/secdef/info".to_owned(), + } + } +} diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 73d8b65..128280f 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -1,3 +1,7 @@ //! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and //! serde DTOs that mirror IBKR's JSON responses losslessly. No auth, no transport, //! no OATH-domain translation. + +pub mod endpoint; + +pub use endpoint::{Endpoint, Method}; diff --git a/crates/adapter/ibkr/tests/endpoint.rs b/crates/adapter/ibkr/tests/endpoint.rs new file mode 100644 index 0000000..dda12bf --- /dev/null +++ b/crates/adapter/ibkr/tests/endpoint.rs @@ -0,0 +1,21 @@ +//! Endpoint path-rendering tests. +use oath_adapter_ibkr::cpapi::{Endpoint, Method}; + +#[test] +fn positions_path_interpolates_account_and_page() { + let ep = Endpoint::positions("U1234567", 0); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/portfolio/U1234567/positions/0"); +} + +#[test] +fn tickle_is_a_post_to_slash_tickle() { + let ep = Endpoint::tickle(); + assert_eq!(ep.method, Method::Post); + assert_eq!(ep.path, "/tickle"); +} + +#[test] +fn secdef_search_is_a_post() { + assert_eq!(Endpoint::secdef_search().method, Method::Post); +} From 740f3f3bd93fe641ae70e6ef5a886cfd86526b2c Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:54:11 +0000 Subject: [PATCH 04/13] feat(ibkr): cpapi error envelope + decode entry point --- crates/adapter/ibkr/Cargo.toml | 5 ---- crates/adapter/ibkr/src/cpapi/error.rs | 38 ++++++++++++++++++++++++++ crates/adapter/ibkr/src/cpapi/mod.rs | 2 ++ crates/adapter/ibkr/tests/error.rs | 24 ++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 crates/adapter/ibkr/src/cpapi/error.rs create mode 100644 crates/adapter/ibkr/tests/error.rs diff --git a/crates/adapter/ibkr/Cargo.toml b/crates/adapter/ibkr/Cargo.toml index 7453481..85ab8c5 100644 --- a/crates/adapter/ibkr/Cargo.toml +++ b/crates/adapter/ibkr/Cargo.toml @@ -12,8 +12,3 @@ workspace = true serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } - -# cargo-machete: deps declared ahead of first use; prune from `ignored` as they -# are adopted (serde/serde_json/thiserror land in the cpapi wire/error modules). -[package.metadata.cargo-machete] -ignored = ["serde", "serde_json", "thiserror"] diff --git a/crates/adapter/ibkr/src/cpapi/error.rs b/crates/adapter/ibkr/src/cpapi/error.rs new file mode 100644 index 0000000..6709be4 --- /dev/null +++ b/crates/adapter/ibkr/src/cpapi/error.rs @@ -0,0 +1,38 @@ +//! The Client Portal API v1 error envelope, this crate's decode error type, and the +//! [`decode`] entry point for turning a response body into a typed value. + +use serde::Deserialize; +use serde::de::DeserializeOwned; +use thiserror::Error; + +/// The JSON error body IBKR returns for a failed Client Portal API v1 request, +/// for example `{"error":"no bridge","statusCode":401}`. +#[derive(Debug, Clone, Deserialize)] +pub struct CpapiError { + /// Human-readable error message. + pub error: String, + /// HTTP-style status code, when present. + #[serde(rename = "statusCode")] + pub status_code: Option, +} + +/// An error decoding a Client Portal API v1 response body. +#[derive(Debug, Error)] +pub enum WireError { + /// The body was not valid JSON for the target type. + #[error("malformed Client Portal API JSON: {0}")] + Json(#[from] serde_json::Error), +} + +/// Deserialize a Client Portal API v1 response body into `T`. +/// +/// The wire layer carries no transport, so the caller (a future HTTP binding) +/// decides — from the HTTP status — whether to `decode::` a success body or +/// `decode::` an error body. +/// +/// # Errors +/// +/// Returns [`WireError::Json`] if `bytes` is not valid JSON for `T`. +pub fn decode(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice(bytes)?) +} diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 128280f..0a39b58 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -3,5 +3,7 @@ //! no OATH-domain translation. pub mod endpoint; +pub mod error; pub use endpoint::{Endpoint, Method}; +pub use error::{CpapiError, WireError, decode}; diff --git a/crates/adapter/ibkr/tests/error.rs b/crates/adapter/ibkr/tests/error.rs new file mode 100644 index 0000000..2a3d355 --- /dev/null +++ b/crates/adapter/ibkr/tests/error.rs @@ -0,0 +1,24 @@ +//! Tests for the CP API v1 error envelope and the `decode` entry point. +use oath_adapter_ibkr::cpapi::{CpapiError, WireError, decode}; + +#[test] +fn error_envelope_decodes() { + let bytes = br#"{"error":"no bridge","statusCode":401}"#; + let err: CpapiError = decode(bytes).expect("error envelope should decode"); + assert_eq!(err.error, "no bridge"); + assert_eq!(err.status_code, Some(401)); +} + +#[test] +fn error_envelope_without_status_code_decodes() { + let bytes = br#"{"error":"Please query /accounts first"}"#; + let err: CpapiError = decode(bytes).expect("bare error should decode"); + assert_eq!(err.status_code, None); +} + +#[test] +fn malformed_json_is_a_wire_error() { + let bytes = b"not json"; + let result: Result = decode(bytes); + assert!(matches!(result, Err(WireError::Json(_)))); +} From 39fb2e72e65a7f5799a06e4c3c90dda78ee5697d Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:01:07 +0000 Subject: [PATCH 05/13] feat(ibkr): cpapi auth/tickle DTOs + fixtures --- crates/adapter/ibkr/src/cpapi/auth.rs | 63 +++++++++++++++++++ crates/adapter/ibkr/src/cpapi/mod.rs | 2 + crates/adapter/ibkr/tests/auth.rs | 20 ++++++ .../tests/fixtures/cpapi/auth_status.json | 1 + .../ibkr/tests/fixtures/cpapi/tickle.json | 1 + 5 files changed, 87 insertions(+) create mode 100644 crates/adapter/ibkr/src/cpapi/auth.rs create mode 100644 crates/adapter/ibkr/tests/auth.rs create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json diff --git a/crates/adapter/ibkr/src/cpapi/auth.rs b/crates/adapter/ibkr/src/cpapi/auth.rs new file mode 100644 index 0000000..9c63aa4 --- /dev/null +++ b/crates/adapter/ibkr/src/cpapi/auth.rs @@ -0,0 +1,63 @@ +//! Session/auth read endpoints: `iserver/auth/status` and `tickle`. + +use serde::Deserialize; + +/// Server identity block embedded in an [`AuthStatus`]. +#[derive(Debug, Clone, Deserialize)] +pub struct ServerInfo { + /// Server name. + #[serde(rename = "serverName")] + pub server_name: Option, + /// Server version string. + #[serde(rename = "serverVersion")] + pub server_version: Option, +} + +/// Response of `GET|POST /iserver/auth/status` — the brokerage-session state. +#[derive(Debug, Clone, Deserialize)] +pub struct AuthStatus { + /// `true` once initial authentication passes. + pub authenticated: bool, + /// `true` when another session is competing for the same account. + pub competing: bool, + /// `true` when connected to the brokerage backend. + pub connected: bool, + /// Optional status message. + #[serde(default)] + pub message: String, + /// Machine access code, when present. + #[serde(rename = "MAC")] + pub mac: Option, + /// Failure reason; empty when healthy. + #[serde(default)] + pub fail: String, + /// Server identity, when present. + #[serde(rename = "serverInfo")] + pub server_info: Option, +} + +/// The `iserver` block of a [`TickleResponse`], wrapping the auth status. +#[derive(Debug, Clone, Deserialize)] +pub struct TickleIServer { + /// The embedded auth status. + #[serde(rename = "authStatus")] + pub auth_status: AuthStatus, +} + +/// Response of `POST /tickle` — session keepalive; also relays the auth status. +#[derive(Debug, Clone, Deserialize)] +pub struct TickleResponse { + /// Opaque session token. + pub session: String, + /// SSO expiry, in seconds, when present. + #[serde(rename = "ssoExpires")] + pub sso_expires: Option, + /// `true` when a session collision occurred. + #[serde(default)] + pub collision: bool, + /// Numeric user id, when present. + #[serde(rename = "userId")] + pub user_id: Option, + /// The relayed `iserver` auth block, when present. + pub iserver: Option, +} diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 0a39b58..7716a82 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -2,8 +2,10 @@ //! serde DTOs that mirror IBKR's JSON responses losslessly. No auth, no transport, //! no OATH-domain translation. +pub mod auth; pub mod endpoint; pub mod error; +pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse}; pub use endpoint::{Endpoint, Method}; pub use error::{CpapiError, WireError, decode}; diff --git a/crates/adapter/ibkr/tests/auth.rs b/crates/adapter/ibkr/tests/auth.rs new file mode 100644 index 0000000..80aed70 --- /dev/null +++ b/crates/adapter/ibkr/tests/auth.rs @@ -0,0 +1,20 @@ +//! Fixture tests for the auth/session DTOs. +use oath_adapter_ibkr::cpapi::{AuthStatus, TickleResponse, decode}; + +#[test] +fn auth_status_deserializes() { + let status: AuthStatus = + decode(include_bytes!("fixtures/cpapi/auth_status.json")).expect("auth_status decodes"); + assert!(status.authenticated); + assert!(status.connected); + assert!(!status.competing); +} + +#[test] +fn tickle_relays_iserver_auth_status() { + let tickle: TickleResponse = + decode(include_bytes!("fixtures/cpapi/tickle.json")).expect("tickle decodes"); + assert!(!tickle.session.is_empty()); + let iserver = tickle.iserver.expect("tickle relays the iserver block"); + assert!(iserver.auth_status.authenticated); +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json b/crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json new file mode 100644 index 0000000..d0e91a9 --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json @@ -0,0 +1 @@ +{"authenticated":true,"competing":false,"connected":true,"message":"","MAC":"00:00:00:00:00:00","serverInfo":{"serverName":"JifN00000","serverVersion":"Build 10.25.0"},"fail":""} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json b/crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json new file mode 100644 index 0000000..fa6015f --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json @@ -0,0 +1 @@ +{"session":"0000000000000000","ssoExpires":600000,"collision":false,"userId":100000000,"hmds":{"error":"no bridge"},"iserver":{"authStatus":{"authenticated":true,"competing":false,"connected":true,"message":"","MAC":"00:00:00:00:00:00","serverInfo":{"serverName":"JifN00000","serverVersion":"Build 10.25.0"},"fail":""}}} From fa58e39bb62d187927dde88f43cc6dbb677b055f Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:09:18 +0000 Subject: [PATCH 06/13] feat(ibkr): cpapi account DTOs + fixtures --- crates/adapter/ibkr/src/cpapi/mod.rs | 2 ++ crates/adapter/ibkr/src/cpapi/portfolio.rs | 29 +++++++++++++++++++ .../fixtures/cpapi/iserver_accounts.json | 1 + .../fixtures/cpapi/portfolio_accounts.json | 1 + crates/adapter/ibkr/tests/portfolio.rs | 21 ++++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 crates/adapter/ibkr/src/cpapi/portfolio.rs create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json create mode 100644 crates/adapter/ibkr/tests/portfolio.rs diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 7716a82..06bb6dc 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -5,7 +5,9 @@ pub mod auth; pub mod endpoint; pub mod error; +pub mod portfolio; pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse}; pub use endpoint::{Endpoint, Method}; pub use error::{CpapiError, WireError, decode}; +pub use portfolio::{IServerAccounts, PortfolioAccount}; diff --git a/crates/adapter/ibkr/src/cpapi/portfolio.rs b/crates/adapter/ibkr/src/cpapi/portfolio.rs new file mode 100644 index 0000000..461ad76 --- /dev/null +++ b/crates/adapter/ibkr/src/cpapi/portfolio.rs @@ -0,0 +1,29 @@ +//! Portfolio read endpoints: `iserver/accounts`, `portfolio/accounts`, and +//! `portfolio/{account}/positions/{page}`. + +use serde::Deserialize; + +/// Response of `GET /iserver/accounts` — accounts the user can trade. +#[derive(Debug, Clone, Deserialize)] +pub struct IServerAccounts { + /// Tradable account ids. + pub accounts: Vec, + /// The currently selected account, when present. + #[serde(rename = "selectedAccount")] + pub selected_account: Option, +} + +/// One element of `GET /portfolio/accounts` — an account for portfolio queries. +#[derive(Debug, Clone, Deserialize)] +pub struct PortfolioAccount { + /// Account id (for example `"DU0000000"`). + pub id: String, + /// Account id (a duplicate field IBKR also returns), when present. + #[serde(rename = "accountId")] + pub account_id: Option, + /// Base currency, when present. + pub currency: Option, + /// Account type — IBKR's `type` field (`"DEMO"` for paper), when present. + #[serde(rename = "type")] + pub account_type: Option, +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json b/crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json new file mode 100644 index 0000000..34a7614 --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json @@ -0,0 +1 @@ +{"accounts":["DU0000000"],"selectedAccount":"DU0000000","isPaper":true} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json b/crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json new file mode 100644 index 0000000..4cdebdb --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json @@ -0,0 +1 @@ +[{"id":"DU0000000","accountId":"DU0000000","currency":"USD","type":"DEMO","displayName":"Paper"}] diff --git a/crates/adapter/ibkr/tests/portfolio.rs b/crates/adapter/ibkr/tests/portfolio.rs new file mode 100644 index 0000000..7268c36 --- /dev/null +++ b/crates/adapter/ibkr/tests/portfolio.rs @@ -0,0 +1,21 @@ +//! Fixture tests for the portfolio DTOs. +use oath_adapter_ibkr::cpapi::{IServerAccounts, PortfolioAccount, decode}; + +#[test] +fn iserver_accounts_deserializes() { + let accts: IServerAccounts = decode(include_bytes!("fixtures/cpapi/iserver_accounts.json")) + .expect("iserver accounts decodes"); + assert_eq!(accts.accounts, vec!["DU0000000".to_owned()]); + assert_eq!(accts.selected_account.as_deref(), Some("DU0000000")); +} + +#[test] +fn portfolio_accounts_deserializes() { + let accts: Vec = + decode(include_bytes!("fixtures/cpapi/portfolio_accounts.json")) + .expect("portfolio accounts decodes"); + assert_eq!(accts.len(), 1); + let first = accts.first().expect("one account"); + assert_eq!(first.id, "DU0000000"); + assert_eq!(first.account_type.as_deref(), Some("DEMO")); +} From 7962af1c1fa11abd506801c6a5d091643f61a58c Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:17:03 +0000 Subject: [PATCH 07/13] feat(ibkr): cpapi Position DTO (conid i64, money as Number) --- crates/adapter/ibkr/src/cpapi/mod.rs | 2 +- crates/adapter/ibkr/src/cpapi/portfolio.rs | 29 +++++++++++++++++++ .../ibkr/tests/fixtures/cpapi/positions.json | 1 + crates/adapter/ibkr/tests/portfolio.rs | 14 +++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/positions.json diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 06bb6dc..184ece4 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -10,4 +10,4 @@ pub mod portfolio; pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse}; pub use endpoint::{Endpoint, Method}; pub use error::{CpapiError, WireError, decode}; -pub use portfolio::{IServerAccounts, PortfolioAccount}; +pub use portfolio::{IServerAccounts, PortfolioAccount, Position}; diff --git a/crates/adapter/ibkr/src/cpapi/portfolio.rs b/crates/adapter/ibkr/src/cpapi/portfolio.rs index 461ad76..fc07101 100644 --- a/crates/adapter/ibkr/src/cpapi/portfolio.rs +++ b/crates/adapter/ibkr/src/cpapi/portfolio.rs @@ -27,3 +27,32 @@ pub struct PortfolioAccount { #[serde(rename = "type")] pub account_type: Option, } + +/// One element of `GET /portfolio/{account}/positions/{page}`. +/// +/// `conid` is an **integer** on this endpoint (contrast `secdef/search`, where the +/// same logical id arrives as a *string* — see `SecdefSearchEntry`). Monetary and +/// quantity fields are kept as `serde_json::Number`: faithful to the wire, precision +/// preserved, no premature `f64`. Conversion to fixed-point (ADR-0023) is the future +/// translation layer's job, not the wire's. +#[derive(Debug, Clone, Deserialize)] +pub struct Position { + /// Account id owning the position, when present. + #[serde(rename = "acctId")] + pub acct_id: Option, + /// IBKR contract id (integer on this endpoint). + pub conid: i64, + /// Signed position size, when present. + pub position: Option, + /// Market price, when present. + #[serde(rename = "mktPrice")] + pub mkt_price: Option, + /// Market value, when present. + #[serde(rename = "mktValue")] + pub mkt_value: Option, + /// Position currency, when present. + pub currency: Option, + /// Contract description, when present. + #[serde(rename = "contractDesc")] + pub contract_desc: Option, +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/positions.json b/crates/adapter/ibkr/tests/fixtures/cpapi/positions.json new file mode 100644 index 0000000..8bcf7fc --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/positions.json @@ -0,0 +1 @@ +[{"acctId":"DU0000000","conid":265598,"contractDesc":"AAPL","position":100,"mktPrice":150.25,"mktValue":15025.0,"currency":"USD","assetClass":"STK"}] diff --git a/crates/adapter/ibkr/tests/portfolio.rs b/crates/adapter/ibkr/tests/portfolio.rs index 7268c36..8cbaa4b 100644 --- a/crates/adapter/ibkr/tests/portfolio.rs +++ b/crates/adapter/ibkr/tests/portfolio.rs @@ -19,3 +19,17 @@ fn portfolio_accounts_deserializes() { assert_eq!(first.id, "DU0000000"); assert_eq!(first.account_type.as_deref(), Some("DEMO")); } + +#[test] +fn positions_deserialize_conid_as_int_and_money_as_number() { + use oath_adapter_ibkr::cpapi::Position; + let positions: Vec = + decode(include_bytes!("fixtures/cpapi/positions.json")).expect("positions decode"); + let p = positions.first().expect("one position"); + assert_eq!(p.conid, 265_598); + // Money stays a serde_json::Number — faithful to the wire, no premature f64. + assert_eq!( + p.mkt_price.as_ref().map(ToString::to_string).as_deref(), + Some("150.25") + ); +} From e05a1cafcd7d168275be580fb729d4dbc6a958fb Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:23:32 +0000 Subject: [PATCH 08/13] feat(ibkr): cpapi secdef DTOs (conid string vs int) --- crates/adapter/ibkr/src/cpapi/mod.rs | 2 + crates/adapter/ibkr/src/cpapi/secdef.rs | 56 +++++++++++++++++++ .../tests/fixtures/cpapi/secdef_info.json | 1 + .../tests/fixtures/cpapi/secdef_search.json | 1 + crates/adapter/ibkr/tests/secdef.rs | 21 +++++++ 5 files changed, 81 insertions(+) create mode 100644 crates/adapter/ibkr/src/cpapi/secdef.rs create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json create mode 100644 crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json create mode 100644 crates/adapter/ibkr/tests/secdef.rs diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 184ece4..63285f0 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -6,8 +6,10 @@ pub mod auth; pub mod endpoint; pub mod error; 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 portfolio::{IServerAccounts, PortfolioAccount, Position}; +pub use secdef::{SecdefInfo, SecdefSearchEntry, SecdefSection}; diff --git a/crates/adapter/ibkr/src/cpapi/secdef.rs b/crates/adapter/ibkr/src/cpapi/secdef.rs new file mode 100644 index 0000000..2b19d7d --- /dev/null +++ b/crates/adapter/ibkr/src/cpapi/secdef.rs @@ -0,0 +1,56 @@ +//! Contract search/info read endpoints: `iserver/secdef/search` and +//! `iserver/secdef/info`. + +use serde::Deserialize; + +/// A tradable section within a `SecdefSearchEntry` (for example `STK`, `OPT`). +#[derive(Debug, Clone, Deserialize)] +pub struct SecdefSection { + /// Security type, for example `"STK"`, `"OPT"`. + #[serde(rename = "secType")] + pub sec_type: String, + /// Available expiry months (`OPT`/`FUT`), when present. + pub months: Option, +} + +/// One element of `POST /iserver/secdef/search`. +/// +/// `conid` is a **string** on this endpoint — the same logical id is an integer on +/// the positions and `secdef/info` endpoints. Modelling each as the wire actually +/// sends it (not a forced shared type) is the faithful-mirror rule (spec §7.4). +#[derive(Debug, Clone, Deserialize)] +pub struct SecdefSearchEntry { + /// IBKR contract id (a string on this endpoint). + pub conid: String, + /// Company name, when present. + #[serde(rename = "companyName")] + pub company_name: Option, + /// Symbol, when present. + pub symbol: Option, + /// Free-text description (often the exchange), when present. + pub description: Option, + /// Tradable sections by security type. + #[serde(default)] + pub sections: Vec, +} + +/// One element of `GET /iserver/secdef/info`. +/// +/// `conid` is an **integer** here (contrast `SecdefSearchEntry`). +#[derive(Debug, Clone, Deserialize)] +pub struct SecdefInfo { + /// IBKR contract id (an integer on this endpoint). + pub conid: i64, + /// Symbol, when present. + pub symbol: Option, + /// Security type, when present. + #[serde(rename = "secType")] + pub sec_type: Option, + /// Primary exchange, when present. + pub exchange: Option, + /// Contract currency, when present. + pub currency: Option, + /// Company name, when present. + #[serde(rename = "companyName")] + pub company_name: Option, +} diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json new file mode 100644 index 0000000..f69b133 --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json @@ -0,0 +1 @@ +[{"conid":265598,"symbol":"AAPL","secType":"STK","exchange":"NASDAQ","currency":"USD","companyName":"APPLE INC"}] diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json new file mode 100644 index 0000000..535c87a --- /dev/null +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json @@ -0,0 +1 @@ +[{"conid":"265598","companyName":"APPLE INC","symbol":"AAPL","description":"NASDAQ","sections":[{"secType":"STK"},{"secType":"OPT","months":"JAN26;FEB26"}]}] diff --git a/crates/adapter/ibkr/tests/secdef.rs b/crates/adapter/ibkr/tests/secdef.rs new file mode 100644 index 0000000..eb2852e --- /dev/null +++ b/crates/adapter/ibkr/tests/secdef.rs @@ -0,0 +1,21 @@ +//! Fixture tests for the secdef DTOs. Note the deliberate conid type split: +//! secdef/search sends conid as a string; secdef/info sends it as an integer. +use oath_adapter_ibkr::cpapi::{SecdefInfo, SecdefSearchEntry, decode}; + +#[test] +fn secdef_search_conid_is_a_string() { + let entries: Vec = + decode(include_bytes!("fixtures/cpapi/secdef_search.json")).expect("search decodes"); + let e = entries.first().expect("one entry"); + assert_eq!(e.conid, "265598"); + assert_eq!(e.sections.len(), 2); +} + +#[test] +fn secdef_info_conid_is_an_int() { + let infos: Vec = + decode(include_bytes!("fixtures/cpapi/secdef_info.json")).expect("info decodes"); + let i = infos.first().expect("one info"); + assert_eq!(i.conid, 265_598); + assert_eq!(i.sec_type.as_deref(), Some("STK")); +} From 50bab74e8c16884bf5e26304be9525a5701a0c88 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:33:16 +0000 Subject: [PATCH 09/13] feat(ibkr): hand-rolled Client Portal Gateway harness + capture recipe --- Justfile | 11 ++++++++-- docker/cpapi/Dockerfile | 17 +++++++++++++++ docker/cpapi/README.md | 36 +++++++++++++++++++++++++++++++ docker/cpapi/capture.sh | 38 +++++++++++++++++++++++++++++++++ docker/cpapi/docker-compose.yml | 13 +++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 docker/cpapi/Dockerfile create mode 100644 docker/cpapi/README.md create mode 100755 docker/cpapi/capture.sh create mode 100644 docker/cpapi/docker-compose.yml diff --git a/Justfile b/Justfile index a4405c6..ccf37d7 100644 --- a/Justfile +++ b/Justfile @@ -87,9 +87,16 @@ gitleaks: actionlint: actionlint -# Lint shell scripts: git hooks and devcontainer provisioning. +# Lint shell scripts: git hooks, devcontainer provisioning, and the IBKR capture harness. shellcheck: - shellcheck .githooks/* .devcontainer/*.sh + shellcheck .githooks/* .devcontainer/*.sh docker/cpapi/*.sh + +# ── IBKR fixture capture ────────────────────────────────────────────────────── + +# Capture Client Portal API v1 read-path fixtures from a running, authenticated +# gateway (see docker/cpapi/README.md). Pass a paper account id. +ibkr-capture account="": + docker/cpapi/capture.sh {{account}} # ── Supply chain & docs ─────────────────────────────────────────────────────── diff --git a/docker/cpapi/Dockerfile b/docker/cpapi/Dockerfile new file mode 100644 index 0000000..7b6d2fa --- /dev/null +++ b/docker/cpapi/Dockerfile @@ -0,0 +1,17 @@ +# Hand-rolled IBKR Client Portal Gateway (CP API v1). The gateway is a plain Java +# web server — no Xvfb/VNC/IBC (that machinery is only for the TWS desktop app). +FROM eclipse-temurin:21-jre + +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/clientportal + +# Download + unpack the gateway distribution (extracts bin/, root/, dist/, ...). +ADD https://download2.interactivebrokers.com/portal/clientportal.gw.zip clientportal.gw.zip +RUN unzip -q clientportal.gw.zip && rm clientportal.gw.zip + +EXPOSE 5000 +# Uses the distribution's shipped root/conf.yaml (listens on :5000). +CMD ["bin/run.sh", "root/conf.yaml"] diff --git a/docker/cpapi/README.md b/docker/cpapi/README.md new file mode 100644 index 0000000..598ec7f --- /dev/null +++ b/docker/cpapi/README.md @@ -0,0 +1,36 @@ +# IBKR Client Portal Gateway (paper) — fixture harness + +A hand-rolled container that runs IBKR's Client Portal API **v1** gateway so we can +log in with a **paper** account and capture read-path responses as test fixtures. +The gateway is a plain Java web server — no Xvfb/VNC/IBC. + +## Prerequisites +- Docker + Docker Compose. +- An IBKR **paper** account with API access enabled. (2FA on the paper login makes + the manual browser step harder; disable it on the paper user if possible.) + +## Run + authenticate +```bash +docker compose -f docker/cpapi/docker-compose.yml up -d --build +# open https://localhost:5000 in a browser, accept the self-signed cert, +# and log in with your PAPER credentials. Leave the tab; the session lives here. +``` +The brokerage session times out after ~5 min idle; `/tickle` keeps it alive. +If the login page rejects you (403 / "not allowed"), the shipped `root/conf.yaml` +`ips.allow` is too strict for container networking — see the commented bind-mount in +`docker-compose.yml`. + +## Capture fixtures +```bash +just ibkr-capture DU0000000 # your paper account id +``` +This writes raw JSON to `crates/adapter/ibkr/tests/fixtures/cpapi/`. +If the script aborts on an endpoint, the brokerage session likely isn't +authenticated — log in at https://localhost:5000 and re-run. + +## 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. +Keep `conid`s (public reference data). `gitleaks` runs in CI — no secrets. diff --git a/docker/cpapi/capture.sh b/docker/cpapi/capture.sh new file mode 100755 index 0000000..da69865 --- /dev/null +++ b/docker/cpapi/capture.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Capture Client Portal API v1 read-path responses from a running, authenticated +# gateway into the crate fixture directory. Log in first at https://localhost:5000. +# Usage: docker/cpapi/capture.sh [ACCOUNT_ID] (or set IBKR_ACCOUNT) +set -euo pipefail + +BASE="${IBKR_GATEWAY:-https://localhost:5000/v1/api}" +OUT="crates/adapter/ibkr/tests/fixtures/cpapi" +ACCOUNT="${1:-${IBKR_ACCOUNT:-}}" +mkdir -p "$OUT" + +# -f: abort on HTTP 4xx/5xx so an unauthenticated/expired session can't be silently written as a fixture. +fetch() { + # $1 = method, $2 = path (relative to BASE), $3 = output filename + curl -fksS -X "$1" "$BASE$2" -o "$OUT/$3" + echo "captured $3" +} + +fetch GET /iserver/auth/status auth_status.json +fetch POST /tickle tickle.json +fetch GET /iserver/accounts iserver_accounts.json +fetch GET /portfolio/accounts portfolio_accounts.json + +if [ -n "$ACCOUNT" ]; then + fetch GET "/portfolio/$ACCOUNT/positions/0" positions.json +else + echo "skipping positions.json: pass an account id (arg 1 or IBKR_ACCOUNT)" +fi + +curl -fksS -X POST "$BASE/iserver/secdef/search" \ + -H 'Content-Type: application/json' \ + -d '{"symbol":"AAPL","name":false,"secType":"STK"}' \ + -o "$OUT/secdef_search.json" +echo "captured secdef_search.json" + +fetch GET "/iserver/secdef/info?conid=265598&secType=STK" secdef_info.json + +echo "DONE. SANITIZE before committing: scrub account ids, balances, and names." diff --git a/docker/cpapi/docker-compose.yml b/docker/cpapi/docker-compose.yml new file mode 100644 index 0000000..00759cb --- /dev/null +++ b/docker/cpapi/docker-compose.yml @@ -0,0 +1,13 @@ +services: + cpgw: + build: . + image: oath-cpapi-gw + container_name: oath-cpapi-gw + ports: + - "5000:5000" + restart: unless-stopped + # If the browser login is blocked (403 / "not allowed"), the gateway's shipped + # root/conf.yaml ips.allow is too strict for container networking. Copy the + # shipped root/conf.yaml out, widen ips.allow for local dev, and bind-mount it: + # volumes: + # - ./conf.override.yaml:/opt/clientportal/root/conf.yaml:ro From 3eb5667ab5e4ead456776978d318f3afdcbf7111 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:46:18 +0000 Subject: [PATCH 10/13] test(ibkr): gated live-gateway integration test (#[ignore]) --- crates/adapter/ibkr/tests/live.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 crates/adapter/ibkr/tests/live.rs diff --git a/crates/adapter/ibkr/tests/live.rs b/crates/adapter/ibkr/tests/live.rs new file mode 100644 index 0000000..703e710 --- /dev/null +++ b/crates/adapter/ibkr/tests/live.rs @@ -0,0 +1,25 @@ +//! Live integration test against a running, authenticated Client Portal Gateway. +//! +//! `#[ignore]` keeps it out of `just ci` — `just test` runs `--all-features`, so a +//! cargo feature would NOT exclude it, but nextest/cargo test skip ignored tests. +//! Run it explicitly (gateway up + logged in at https://localhost:5000): +//! cargo test -p oath-adapter-ibkr --test live -- --ignored +//! # or: cargo nextest run -p oath-adapter-ibkr --run-ignored +use std::process::Command; + +use oath_adapter_ibkr::cpapi::{AuthStatus, decode}; + +#[test] +#[ignore = "requires a live, authenticated Client Portal Gateway on https://localhost:5000"] +fn live_auth_status_deserializes() { + let base = std::env::var("IBKR_GATEWAY") + .unwrap_or_else(|_| "https://localhost:5000/v1/api".to_owned()); + let output = Command::new("curl") + .args(["-ksS", "-X", "GET", &format!("{base}/iserver/auth/status")]) + .output() + .expect("curl should run"); + assert!(output.status.success(), "curl failed: {output:?}"); + // Decoding is the assertion; `authenticated` depends on live login state. + let _status: AuthStatus = + decode(&output.stdout).expect("live auth/status should decode into AuthStatus"); +} From dabf3cbad7e2603cc1b33f15989a90cc0d2c0d6b Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:53:06 +0000 Subject: [PATCH 11/13] docs(ibkr): README crate row + graph node + CHANGELOG entry --- CHANGELOG.md | 9 +++++++++ README.md | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e98461..a38b14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`oath-adapter-ibkr` (new crate) — IBKR Client Portal API v1 read-path wire layer.** + Transport-agnostic serde DTOs for the CP API v1 read endpoints (`iserver/auth/status`, + `tickle`, `iserver/accounts`, `portfolio/accounts`, `portfolio/{acct}/positions`, + `iserver/secdef/search` / `info`), an `Endpoint` descriptor, and a `decode` entry point. + Depends only on `serde`/`serde_json`/`thiserror`; no OATH-domain translation yet + (deferred until `InstrumentId`/`Order` land, per ADR-0003/0025/0026). Ships a + hand-rolled Client Portal Gateway container (`docker/cpapi/`) and a `just ibkr-capture` + recipe for paper-account fixtures. Web API (beta OAuth 2.0) and TWS (socket) are future + sibling modules. - **net-http operability.** `HyperLeaf::shutdown()` drains in-flight requests (`await`s until an `Arc`-shared in-flight count reaches zero) so pooled connections can be dropped without `RST`ing an in-flight order submission; it diff --git a/README.md b/README.md index ac2b073..13dcb0c 100644 --- a/README.md +++ b/README.md @@ -25,6 +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-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) | @@ -45,6 +46,7 @@ graph TD netapi[oath-adapter-net-api] nethttpapi[oath-adapter-net-http-api] --> netapi netwsapi[oath-adapter-net-ws-api] --> netapi + ibkr[oath-adapter-ibkr] risk[oath-core-risk] --> coreapi risk --> model @@ -68,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`) and venue adapters (e.g. `oath-adapter-ibkr`) are coming soon. +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. ## Setup From bc253368d121795eeb6289fb079515137c6846a7 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:15:23 +0000 Subject: [PATCH 12/13] refactor(ibkr): address final-review findings (endpoint, docs, harness) - secdef_info(conid, sec_type) now carries required query params - Endpoint doc clarifies method+path/query only, bodies deferred - tests/endpoint.rs asserts all seven endpoint constructors - fix sso_expires unit doc (milliseconds, not seconds) - harden live.rs curl (-fksS) + document IBKR_GATEWAY override - Dockerfile: drop unused curl, document unpinned-download tradeoff - soften "lossless" wording in cpapi/mod.rs to faithful-subset framing - docker-compose.yml: restart: "no" for the auth-session harness - capture.sh: note it must run from the repo root --- crates/adapter/ibkr/src/cpapi/auth.rs | 3 +- crates/adapter/ibkr/src/cpapi/endpoint.rs | 10 ++++-- crates/adapter/ibkr/src/cpapi/mod.rs | 8 +++-- crates/adapter/ibkr/tests/endpoint.rs | 43 +++++++++++++++++++---- crates/adapter/ibkr/tests/live.rs | 5 ++- docker/cpapi/Dockerfile | 4 ++- docker/cpapi/capture.sh | 2 ++ docker/cpapi/docker-compose.yml | 2 +- 8 files changed, 63 insertions(+), 14 deletions(-) diff --git a/crates/adapter/ibkr/src/cpapi/auth.rs b/crates/adapter/ibkr/src/cpapi/auth.rs index 9c63aa4..89d6dab 100644 --- a/crates/adapter/ibkr/src/cpapi/auth.rs +++ b/crates/adapter/ibkr/src/cpapi/auth.rs @@ -49,7 +49,8 @@ pub struct TickleIServer { pub struct TickleResponse { /// Opaque session token. pub session: String, - /// SSO expiry, in seconds, when present. + /// SSO session expiry countdown — raw wire value (IBKR reports this in + /// milliseconds), when present. #[serde(rename = "ssoExpires")] pub sso_expires: Option, /// `true` when a session collision occurred. diff --git a/crates/adapter/ibkr/src/cpapi/endpoint.rs b/crates/adapter/ibkr/src/cpapi/endpoint.rs index d71a9af..66e1967 100644 --- a/crates/adapter/ibkr/src/cpapi/endpoint.rs +++ b/crates/adapter/ibkr/src/cpapi/endpoint.rs @@ -15,6 +15,11 @@ pub enum Method { /// A Client Portal API v1 endpoint: an HTTP [`Method`] and a path relative to the /// `/v1/api` base (for example `/portfolio/accounts`). +/// +/// 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. #[derive(Debug, Clone)] pub struct Endpoint { /// The HTTP method. @@ -80,11 +85,12 @@ impl Endpoint { } /// `GET /iserver/secdef/info` — contract details (call after `secdef_search`). + /// `conid` and `sec_type` are required query params. #[must_use] - pub fn secdef_info() -> Self { + pub fn secdef_info(conid: i64, sec_type: &str) -> Self { Self { method: Method::Get, - path: "/iserver/secdef/info".to_owned(), + path: format!("/iserver/secdef/info?conid={conid}&secType={sec_type}"), } } } diff --git a/crates/adapter/ibkr/src/cpapi/mod.rs b/crates/adapter/ibkr/src/cpapi/mod.rs index 63285f0..983275a 100644 --- a/crates/adapter/ibkr/src/cpapi/mod.rs +++ b/crates/adapter/ibkr/src/cpapi/mod.rs @@ -1,6 +1,10 @@ //! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and -//! serde DTOs that mirror IBKR's JSON responses losslessly. No auth, no transport, -//! no OATH-domain translation. +//! 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 +//! back — this is a faithful subset, not a byte-for-byte round trip. pub mod auth; pub mod endpoint; diff --git a/crates/adapter/ibkr/tests/endpoint.rs b/crates/adapter/ibkr/tests/endpoint.rs index dda12bf..a0dbdf2 100644 --- a/crates/adapter/ibkr/tests/endpoint.rs +++ b/crates/adapter/ibkr/tests/endpoint.rs @@ -1,11 +1,12 @@ -//! Endpoint path-rendering tests. +//! Endpoint path-rendering tests — one assertion group per constructor, covering +//! all seven Client Portal API v1 read-path endpoints. use oath_adapter_ibkr::cpapi::{Endpoint, Method}; #[test] -fn positions_path_interpolates_account_and_page() { - let ep = Endpoint::positions("U1234567", 0); +fn auth_status_is_a_get_to_iserver_auth_status() { + let ep = Endpoint::auth_status(); assert_eq!(ep.method, Method::Get); - assert_eq!(ep.path, "/portfolio/U1234567/positions/0"); + assert_eq!(ep.path, "/iserver/auth/status"); } #[test] @@ -16,6 +17,36 @@ fn tickle_is_a_post_to_slash_tickle() { } #[test] -fn secdef_search_is_a_post() { - assert_eq!(Endpoint::secdef_search().method, Method::Post); +fn iserver_accounts_is_a_get_to_iserver_accounts() { + let ep = Endpoint::iserver_accounts(); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/iserver/accounts"); +} + +#[test] +fn portfolio_accounts_is_a_get_to_portfolio_accounts() { + let ep = Endpoint::portfolio_accounts(); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/portfolio/accounts"); +} + +#[test] +fn positions_path_interpolates_account_and_page() { + let ep = Endpoint::positions("U1234567", 0); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/portfolio/U1234567/positions/0"); +} + +#[test] +fn secdef_search_is_a_post_to_iserver_secdef_search() { + let ep = Endpoint::secdef_search(); + assert_eq!(ep.method, Method::Post); + assert_eq!(ep.path, "/iserver/secdef/search"); +} + +#[test] +fn secdef_info_path_interpolates_conid_and_sec_type() { + let ep = Endpoint::secdef_info(265_598, "STK"); + assert_eq!(ep.method, Method::Get); + assert_eq!(ep.path, "/iserver/secdef/info?conid=265598&secType=STK"); } diff --git a/crates/adapter/ibkr/tests/live.rs b/crates/adapter/ibkr/tests/live.rs index 703e710..23e705b 100644 --- a/crates/adapter/ibkr/tests/live.rs +++ b/crates/adapter/ibkr/tests/live.rs @@ -5,6 +5,9 @@ //! Run it explicitly (gateway up + logged in at https://localhost:5000): //! cargo test -p oath-adapter-ibkr --test live -- --ignored //! # or: cargo nextest run -p oath-adapter-ibkr --run-ignored +//! +//! An `IBKR_GATEWAY` override must include the `/v1/api` path segment (the default +//! is `https://localhost:5000/v1/api`). use std::process::Command; use oath_adapter_ibkr::cpapi::{AuthStatus, decode}; @@ -15,7 +18,7 @@ fn live_auth_status_deserializes() { let base = std::env::var("IBKR_GATEWAY") .unwrap_or_else(|_| "https://localhost:5000/v1/api".to_owned()); let output = Command::new("curl") - .args(["-ksS", "-X", "GET", &format!("{base}/iserver/auth/status")]) + .args(["-fksS", "-X", "GET", &format!("{base}/iserver/auth/status")]) .output() .expect("curl should run"); assert!(output.status.success(), "curl failed: {output:?}"); diff --git a/docker/cpapi/Dockerfile b/docker/cpapi/Dockerfile index 7b6d2fa..d05da4b 100644 --- a/docker/cpapi/Dockerfile +++ b/docker/cpapi/Dockerfile @@ -3,12 +3,14 @@ FROM eclipse-temurin:21-jre RUN apt-get update \ - && apt-get install -y --no-install-recommends unzip curl ca-certificates \ + && apt-get install -y --no-install-recommends unzip ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /opt/clientportal # Download + unpack the gateway distribution (extracts bin/, root/, dist/, ...). +# Unpinned: fetched over HTTPS from IBKR's official domain; IBKR ships no +# versioned/checksummed artifact, and this dev-only harness is never built in CI. ADD https://download2.interactivebrokers.com/portal/clientportal.gw.zip clientportal.gw.zip RUN unzip -q clientportal.gw.zip && rm clientportal.gw.zip diff --git a/docker/cpapi/capture.sh b/docker/cpapi/capture.sh index da69865..2c0a71b 100755 --- a/docker/cpapi/capture.sh +++ b/docker/cpapi/capture.sh @@ -2,6 +2,8 @@ # Capture Client Portal API v1 read-path responses from a running, authenticated # gateway into the crate fixture directory. Log in first at https://localhost:5000. # Usage: docker/cpapi/capture.sh [ACCOUNT_ID] (or set IBKR_ACCOUNT) +# Must be run from the repo root — OUT below is repo-root-relative. `just ibkr-capture` +# already does this. set -euo pipefail BASE="${IBKR_GATEWAY:-https://localhost:5000/v1/api}" diff --git a/docker/cpapi/docker-compose.yml b/docker/cpapi/docker-compose.yml index 00759cb..c5d2fc7 100644 --- a/docker/cpapi/docker-compose.yml +++ b/docker/cpapi/docker-compose.yml @@ -5,7 +5,7 @@ services: container_name: oath-cpapi-gw ports: - "5000:5000" - restart: unless-stopped + restart: "no" # If the browser login is blocked (403 / "not allowed"), the gateway's shipped # root/conf.yaml ips.allow is too strict for container networking. Copy the # shipped root/conf.yaml out, widen ips.allow for local dev, and bind-mount it: From a4d89084066fd534a9a853e0076c1161552452c5 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:08:35 +0000 Subject: [PATCH 13/13] chore(ibkr): apply CodeRabbit findings (max-time, non-root, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit's four findings on PR #127: - tests/live.rs, docker/cpapi/capture.sh: add `curl --max-time 30` so a stalled/partially-reachable gateway fails fast instead of hanging. - docker/cpapi/Dockerfile: run the Java gateway as a non-root `ibkr` user (Trivy DS-0002). Validated by build+run — starts clean as uid 999, no permission errors. - docker/cpapi/capture.sh, tests/live.rs: document why `-k` (the gateway's self-signed cert) so it isn't "fixed" away. - docs/superpowers/plans/…: sync the stale `secdef_info()` example with the shipped `secdef_info(conid, sec_type)` signature. Co-Authored-By: Claude Opus 4.8 --- crates/adapter/ibkr/tests/live.rs | 11 ++++++++++- docker/cpapi/Dockerfile | 7 +++++++ docker/cpapi/capture.sh | 6 ++++-- .../plans/2026-07-10-ibkr-cpapi-readpath-wire.md | 10 +++++++--- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/adapter/ibkr/tests/live.rs b/crates/adapter/ibkr/tests/live.rs index 23e705b..3901fef 100644 --- a/crates/adapter/ibkr/tests/live.rs +++ b/crates/adapter/ibkr/tests/live.rs @@ -17,8 +17,17 @@ use oath_adapter_ibkr::cpapi::{AuthStatus, decode}; fn live_auth_status_deserializes() { let base = std::env::var("IBKR_GATEWAY") .unwrap_or_else(|_| "https://localhost:5000/v1/api".to_owned()); + // -f: fail on HTTP 4xx/5xx; -k: skip TLS verify (the gateway ships a self-signed cert); + // --max-time: fail fast instead of hanging on a stalled/partially-reachable gateway. let output = Command::new("curl") - .args(["-fksS", "-X", "GET", &format!("{base}/iserver/auth/status")]) + .args([ + "-fksS", + "--max-time", + "30", + "-X", + "GET", + &format!("{base}/iserver/auth/status"), + ]) .output() .expect("curl should run"); assert!(output.status.success(), "curl failed: {output:?}"); diff --git a/docker/cpapi/Dockerfile b/docker/cpapi/Dockerfile index d05da4b..9593daa 100644 --- a/docker/cpapi/Dockerfile +++ b/docker/cpapi/Dockerfile @@ -14,6 +14,13 @@ WORKDIR /opt/clientportal ADD https://download2.interactivebrokers.com/portal/clientportal.gw.zip clientportal.gw.zip RUN unzip -q clientportal.gw.zip && rm clientportal.gw.zip +# Run the Java gateway as a non-root user (Trivy DS-0002). Port 5000 is >1024, so +# an unprivileged user can bind it; the gateway writes logs/session under its dir, +# which we hand to `ibkr`. +RUN useradd -r -m -d /opt/clientportal -s /sbin/nologin ibkr \ + && chown -R ibkr:ibkr /opt/clientportal +USER ibkr + EXPOSE 5000 # Uses the distribution's shipped root/conf.yaml (listens on :5000). CMD ["bin/run.sh", "root/conf.yaml"] diff --git a/docker/cpapi/capture.sh b/docker/cpapi/capture.sh index 2c0a71b..1a86cf7 100755 --- a/docker/cpapi/capture.sh +++ b/docker/cpapi/capture.sh @@ -12,9 +12,11 @@ ACCOUNT="${1:-${IBKR_ACCOUNT:-}}" mkdir -p "$OUT" # -f: abort on HTTP 4xx/5xx so an unauthenticated/expired session can't be silently written as a fixture. +# -k: the local gateway ships a self-signed TLS cert, so skip verification (dev-only; use --cacert if IBKR ships a CA). +# --max-time: fail fast instead of hanging if the gateway stalls or is only partially reachable. fetch() { # $1 = method, $2 = path (relative to BASE), $3 = output filename - curl -fksS -X "$1" "$BASE$2" -o "$OUT/$3" + curl -fksS --max-time 30 -X "$1" "$BASE$2" -o "$OUT/$3" echo "captured $3" } @@ -29,7 +31,7 @@ else echo "skipping positions.json: pass an account id (arg 1 or IBKR_ACCOUNT)" fi -curl -fksS -X POST "$BASE/iserver/secdef/search" \ +curl -fksS --max-time 30 -X POST "$BASE/iserver/secdef/search" \ -H 'Content-Type: application/json' \ -d '{"symbol":"AAPL","name":false,"secType":"STK"}' \ -o "$OUT/secdef_search.json" diff --git a/docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md b/docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md index 12366ce..fa7d177 100644 --- a/docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md +++ b/docs/superpowers/plans/2026-07-10-ibkr-cpapi-readpath-wire.md @@ -174,7 +174,7 @@ git commit -m "feat(ibkr): scaffold oath-adapter-ibkr crate + cpapi module" - Test: `crates/adapter/ibkr/tests/endpoint.rs` **Interfaces:** -- Produces: `Method { Get, Post }`; `Endpoint { method: Method, path: String }`; constructors `Endpoint::auth_status()`, `tickle()`, `iserver_accounts()`, `portfolio_accounts()`, `positions(account_id: &str, page: u32)`, `secdef_search()`, `secdef_info()`. Paths are relative to the gateway base `…/v1/api`. +- Produces: `Method { Get, Post }`; `Endpoint { method: Method, path: String }`; constructors `Endpoint::auth_status()`, `tickle()`, `iserver_accounts()`, `portfolio_accounts()`, `positions(account_id: &str, page: u32)`, `secdef_search()`, `secdef_info(conid: i64, sec_type: &str)`. Paths are relative to the gateway base `…/v1/api`. - [ ] **Step 1: Write the failing test** @@ -281,9 +281,13 @@ impl Endpoint { } /// `GET /iserver/secdef/info` — contract details (call after `secdef_search`). + /// `conid` and `sec_type` are required query params. #[must_use] - pub fn secdef_info() -> Self { - Self { method: Method::Get, path: "/iserver/secdef/info".to_owned() } + pub fn secdef_info(conid: i64, sec_type: &str) -> Self { + Self { + method: Method::Get, + path: format!("/iserver/secdef/info?conid={conid}&secType={sec_type}"), + } } } ```