Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,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
Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ members = [
"crates/core/host",
"crates/strategy/api",
"crates/adapter/api",
"crates/adapter/ibkr",
"crates/strategy/host",
"crates/cli",
"crates/supervisor",
Expand Down Expand Up @@ -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" }

Expand Down
11 changes: 9 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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
Expand All @@ -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

Expand Down
9 changes: 9 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
14 changes: 14 additions & 0 deletions crates/adapter/ibkr/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[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 }
64 changes: 64 additions & 0 deletions crates/adapter/ibkr/src/cpapi/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! 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<String>,
/// Server version string.
#[serde(rename = "serverVersion")]
pub server_version: Option<String>,
}

/// 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<String>,
/// Failure reason; empty when healthy.
#[serde(default)]
pub fail: String,
/// Server identity, when present.
#[serde(rename = "serverInfo")]
pub server_info: Option<ServerInfo>,
}

/// 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 session expiry countdown — raw wire value (IBKR reports this in
/// milliseconds), when present.
#[serde(rename = "ssoExpires")]
pub sso_expires: Option<i64>,
/// `true` when a session collision occurred.
#[serde(default)]
pub collision: bool,
/// Numeric user id, when present.
#[serde(rename = "userId")]
pub user_id: Option<i64>,
/// The relayed `iserver` auth block, when present.
pub iserver: Option<TickleIServer>,
}
96 changes: 96 additions & 0 deletions crates/adapter/ibkr/src/cpapi/endpoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! 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`).
///
/// 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.
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`).
/// `conid` and `sec_type` are required query params.
#[must_use]
pub fn secdef_info(conid: i64, sec_type: &str) -> Self {
Self {
method: Method::Get,
path: format!("/iserver/secdef/info?conid={conid}&secType={sec_type}"),
}
}
}
38 changes: 38 additions & 0 deletions crates/adapter/ibkr/src/cpapi/error.rs
Original file line number Diff line number Diff line change
@@ -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<i64>,
}

/// 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::<T>` a success body or
/// `decode::<CpapiError>` an error body.
///
/// # Errors
///
/// Returns [`WireError::Json`] if `bytes` is not valid JSON for `T`.
pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, WireError> {
Ok(serde_json::from_slice(bytes)?)
}
19 changes: 19 additions & 0 deletions crates/adapter/ibkr/src/cpapi/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and
//! serde DTOs that mirror IBKR's JSON responses. No auth, no transport, no
//! OATH-domain translation.
//!
//! 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;
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};
58 changes: 58 additions & 0 deletions crates/adapter/ibkr/src/cpapi/portfolio.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! 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<String>,
/// The currently selected account, when present.
#[serde(rename = "selectedAccount")]
pub selected_account: Option<String>,
}

/// 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<String>,
/// Base currency, when present.
pub currency: Option<String>,
/// Account type — IBKR's `type` field (`"DEMO"` for paper), when present.
#[serde(rename = "type")]
pub account_type: Option<String>,
}

/// 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<String>,
/// IBKR contract id (integer on this endpoint).
pub conid: i64,
/// Signed position size, when present.
pub position: Option<serde_json::Number>,
/// Market price, when present.
#[serde(rename = "mktPrice")]
pub mkt_price: Option<serde_json::Number>,
/// Market value, when present.
#[serde(rename = "mktValue")]
pub mkt_value: Option<serde_json::Number>,
/// Position currency, when present.
pub currency: Option<String>,
/// Contract description, when present.
#[serde(rename = "contractDesc")]
pub contract_desc: Option<String>,
}
Loading