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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`oath-adapter-ibkr` — CP API v1 order write-path wire layer.** Request-body serde
DTOs (`OrderRequest`/`PlaceOrderRequest`/`ReplyConfirm` — the crate's first serialize
direction), the order/reply union response (`OrderPlaceReply`, one all-optional struct),
and cancel/status/live-orders response DTOs (`CancelResponse`, `OrderStatus`,
`LiveOrders`/`LiveOrder`), plus `Endpoint` descriptors for place / reply-confirm /
cancel / order-status / live-orders (`Method::Delete` added). No transport, auth, or
OATH-domain translation (ADR-0022/0026 untouched). Fixtures are real, sanitized paper
captures via an extended `just ibkr-capture` order dance; a gated `#[ignore]` live test
drives a place → confirm → cancel round-trip.

### Changed

- **net-api compose polish — no behaviour change.** Added `#[must_use]` to
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Every subsystem is defined behind a trait. Backends, adapters, transports, and s
| `oath-adapter-net-api` | Transport-neutral composition primitives (`Layer`, `LayerBuilder`, `Stack`) + `ErrorKind` / `Timer` |
| `oath-adapter-net-http-api` | HTTP transport contract (`Service`, …) over the `oath-adapter-net-api` kernel |
| `oath-adapter-net-ws-api` | WebSocket transport contract (`Frame`, `WsSink`/`WsSource`, `Lifecycle`, `WsConnector`, …) over the `oath-adapter-net-api` kernel |
| `oath-adapter-ibkr` | IBKR venue adapter — Client Portal API v1 (`cpapi`) read-path wire layer; `webapi` (beta OAuth) / `tws` (socket) surfaces to follow |
| `oath-adapter-ibkr` | IBKR venue adapter — Client Portal API v1 (`cpapi`) read + order-write wire layer; `webapi` (beta OAuth) / `tws` (socket) surfaces to follow |
| `oath-strategy-api` | User-facing `Strategy` trait and Signal ergonomics (the canonical `Signal` payload lives in `oath-model`, per ADR-0028) |
| `oath-strategy-host` | Strategy Node binary: hosts user strategies, isolated from Core |
| `oath-cli` | The first Frontend (MVP) |
Expand Down Expand Up @@ -70,7 +70,7 @@ graph TD
sup[oath-supervisor] --> model
```

The crates above are compiling skeletons. Bus/Event-Log/persistence backends (e.g. `oath-bus-iceoryx2`, `oath-event-log-chronicle`, `oath-persistence-sqlite`) are coming soon. The first venue adapter, `oath-adapter-ibkr`, has begun with its Client Portal API v1 read-path wire layer.
The crates above are compiling skeletons. Bus/Event-Log/persistence backends (e.g. `oath-bus-iceoryx2`, `oath-event-log-chronicle`, `oath-persistence-sqlite`) are coming soon. The first venue adapter, `oath-adapter-ibkr`, covers the Client Portal API v1 read and order-write wire layers.

## Setup

Expand Down
57 changes: 54 additions & 3 deletions crates/adapter/ibkr/src/cpapi/endpoint.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Endpoint descriptors for the Client Portal API v1 read path.
//! Endpoint descriptors for the Client Portal API v1 — the read path and the
//! order write path.
//!
//! An [`Endpoint`] is a pure value — an HTTP [`Method`] plus a path *relative to the
//! gateway base URL* (`https://localhost:5000/v1/api`). This layer carries no
Expand All @@ -11,15 +12,18 @@ pub enum Method {
Get,
/// HTTP `POST`.
Post,
/// HTTP `DELETE`.
Delete,
}

/// A Client Portal API v1 endpoint: an HTTP [`Method`] and a path relative to the
/// `/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.
/// payload `{symbol, secType}`, or the order-submission and reply-confirm bodies
/// of the write path) are supplied by the future request/transport binding and
/// are not modeled here.
#[derive(Debug, Clone)]
pub struct Endpoint {
/// The HTTP method.
Expand Down Expand Up @@ -96,4 +100,51 @@ impl Endpoint {
path: format!("/iserver/secdef/info?conid={conid}"),
}
}

/// `POST /iserver/account/{account_id}/orders` — submit one or more orders.
/// The body (a `PlaceOrderRequest`) is supplied by the transport, not this descriptor.
#[must_use]
pub fn place_orders(account_id: &str) -> Self {
Self {
method: Method::Post,
path: format!("/iserver/account/{account_id}/orders"),
}
}

/// `POST /iserver/reply/{reply_id}` — confirm a suppressible order warning
/// (body `{"confirmed":true}`, a `ReplyConfirm`).
#[must_use]
pub fn reply(reply_id: &str) -> Self {
Self {
method: Method::Post,
path: format!("/iserver/reply/{reply_id}"),
}
}

/// `DELETE /iserver/account/{account_id}/order/{order_id}` — cancel a live order.
#[must_use]
pub fn cancel_order(account_id: &str, order_id: &str) -> Self {
Self {
method: Method::Delete,
path: format!("/iserver/account/{account_id}/order/{order_id}"),
}
}

/// `GET /iserver/account/order/status/{order_id}` — status of a single order.
#[must_use]
pub fn order_status(order_id: &str) -> Self {
Self {
method: Method::Get,
path: format!("/iserver/account/order/status/{order_id}"),
}
}

/// `GET /iserver/account/orders` — the account's live orders.
#[must_use]
pub fn live_orders() -> Self {
Self {
method: Method::Get,
path: "/iserver/account/orders".to_owned(),
}
}
}
13 changes: 10 additions & 3 deletions crates/adapter/ibkr/src/cpapi/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Client Portal API v1 (`cpapi`) read-path wire layer: endpoint descriptors and
//! serde DTOs that mirror IBKR's JSON responses. No auth, no transport, no
//! OATH-domain translation.
//! Client Portal API v1 (`cpapi`) wire layer: endpoint descriptors and serde DTOs for
//! the read path and the order write path.
//!
//! Endpoint descriptors and serde DTOs that mirror IBKR's JSON responses. No auth,
//! no transport, no OATH-domain translation.
//!
//! The DTOs faithfully mirror the *modeled* fields; unmodeled fields (for
//! example `assetClass`, `isPaper`, `hmds`) are silently ignored, not echoed
Expand All @@ -9,11 +11,16 @@
pub mod auth;
pub mod endpoint;
pub mod error;
pub mod order;
pub mod portfolio;
pub mod secdef;

pub use auth::{AuthStatus, ServerInfo, TickleIServer, TickleResponse};
pub use endpoint::{Endpoint, Method};
pub use error::{CpapiError, WireError, decode};
pub use order::{
CancelResponse, LiveOrder, LiveOrders, OrderPlaceReply, OrderRequest, OrderStatus,
PlaceOrderRequest, ReplyConfirm,
};
pub use portfolio::{IServerAccounts, PortfolioAccount, Position};
pub use secdef::{SecdefInfo, SecdefSearchEntry, SecdefSection};
169 changes: 169 additions & 0 deletions crates/adapter/ibkr/src/cpapi/order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//! Order write-path wire layer: request bodies for placing and confirming orders,
//! plus the order-lifecycle response DTOs (place/reply union, cancel, status, live orders).
//!
//! Faithfully mirrors Client Portal API v1 JSON — no transport, no auth, no
//! OATH-domain translation, no order-safety semantics. `side`, `orderType`, and
//! `tif` are kept as `String` (the wire's own tokens); the mapping onto OATH's
//! domain types is the deferred translation layer's job.

use serde::{Deserialize, Serialize};

/// One order in a `POST /iserver/account/{account}/orders` request body.
///
/// A focused subset of IBKR's order fields — enough for common equity orders. Exotic
/// features (bracket / OCA groups, trailing stops, algo params) are a later slice.
/// `quantity` / `price` / `aux_price` are `serde_json::Number` (no premature `f64`);
/// the translation layer produces exact values from OATH fixed-point (ADR-0023).
#[derive(Debug, Clone, Serialize)]
pub struct OrderRequest {
/// IBKR contract id.
pub conid: i64,
/// Order side — `"BUY"` or `"SELL"` (the wire's own token; no enum here).
pub side: String,
/// Order type — `"LMT"`, `"MKT"`, `"STP"`, ….
#[serde(rename = "orderType")]
pub order_type: String,
/// Order quantity.
pub quantity: serde_json::Number,
/// Time in force — `"DAY"`, `"GTC"`, ….
pub tif: String,
/// Limit price (for `LMT`), when applicable.
#[serde(skip_serializing_if = "Option::is_none")]
pub price: Option<serde_json::Number>,
/// Stop / auxiliary price (for `STP`), when applicable.
#[serde(rename = "auxPrice", skip_serializing_if = "Option::is_none")]
pub aux_price: Option<serde_json::Number>,
/// Customer order id (`cOID`) — a client-supplied idempotency tag. Carried through
/// verbatim; this layer does not generate or interpret it.
#[serde(rename = "cOID", skip_serializing_if = "Option::is_none")]
pub coid: Option<String>,
/// Allow execution outside regular trading hours, when set.
#[serde(rename = "outsideRTH", skip_serializing_if = "Option::is_none")]
pub outside_rth: Option<bool>,
}

/// Body of `POST /iserver/account/{account}/orders` — a batch of orders.
#[derive(Debug, Clone, Serialize)]
pub struct PlaceOrderRequest {
/// The orders to submit.
pub orders: Vec<OrderRequest>,
}

/// Body of `POST /iserver/reply/{reply_id}` — confirm (or decline) a suppressible
/// order warning.
#[derive(Debug, Clone, Serialize)]
pub struct ReplyConfirm {
/// `true` to confirm the warning and proceed.
pub confirmed: bool,
}

/// One element of a place-order or reply-confirm response.
///
/// The Client Portal API returns *either* a list of suppressible warning **questions**
/// (confirm each via `POST /iserver/reply/{id}`) *or* a list of order **confirmations**
/// — from both `POST …/orders` and `POST /iserver/reply/{id}`. Rather than a serde
/// `untagged` enum (order-sensitive, poor errors), this is one all-optional struct
/// carrying both shapes; the caller inspects which fields are present. `decode` it as
/// `Vec<OrderPlaceReply>`.
///
/// `order_id` is a **string** here; on `order/status` and `account/orders` the same
/// logical id arrives as an **integer** (`OrderStatus`, `LiveOrder`) — the faithful
/// mirror keeps each as the wire sends it.
#[derive(Debug, Clone, Deserialize)]
pub struct OrderPlaceReply {
/// Question id to echo back to `POST /iserver/reply/{id}` (question shape).
pub id: Option<String>,
/// Human-readable warning lines (question shape).
pub message: Option<Vec<String>>,
/// Whether this warning can be suppressed (question shape).
#[serde(rename = "isSuppressed")]
pub is_suppressed: Option<bool>,
/// Placed order id, as a string (confirmation shape).
pub order_id: Option<String>,
/// Order status, e.g. `"PreSubmitted"` (confirmation shape).
pub order_status: Option<String>,
/// Opaque encrypt-message token IBKR echoes on confirmation (confirmation shape).
pub encrypt_message: Option<String>,
}

/// Response of `DELETE /iserver/account/{account}/order/{order_id}` — cancel ack.
#[derive(Debug, Clone, Deserialize)]
pub struct CancelResponse {
/// The cancelled order id (integer on this endpoint).
pub order_id: Option<i64>,
/// Human-readable acknowledgement, e.g. `"Request was submitted"`.
pub msg: Option<String>,
/// Contract id, when present.
pub conid: Option<i64>,
/// Account id, when present.
pub account: Option<String>,
}

/// Response of `GET /iserver/account/order/status/{order_id}`.
///
/// This endpoint is **`snake_case`**-native — no serde renames. Sizes and the limit
/// price arrive as strings (kept faithfully as `String`).
#[derive(Debug, Clone, Deserialize)]
pub struct OrderStatus {
/// Order id (integer on this endpoint).
pub order_id: Option<i64>,
/// Contract id, when present.
pub conid: Option<i64>,
/// Symbol, when present.
pub symbol: Option<String>,
/// Side token (e.g. `"B"`/`"S"`), when present.
pub side: Option<String>,
/// Order type token, when present.
pub order_type: Option<String>,
/// Order status, when present.
pub order_status: Option<String>,
/// Total order size, sent as a string.
pub total_size: Option<String>,
/// Cumulative filled size, sent as a string.
pub cum_fill: Option<String>,
/// Limit price, sent as a string (the wire field is `limit_price`), when present.
pub limit_price: Option<String>,
/// Time in force, when present.
pub tif: Option<String>,
/// Account id, when present.
pub account: Option<String>,
}

/// Response of `GET /iserver/account/orders` — the account's live orders.
///
/// This endpoint is **`camelCase`**-native, so each `LiveOrder` renames `orderId` /
/// `orderType` / `totalSize` — contrast the snake-native `OrderStatus`.
#[derive(Debug, Clone, Deserialize)]
pub struct LiveOrders {
/// The live orders (may be empty; a first call can return a warming snapshot).
#[serde(default)]
pub orders: Vec<LiveOrder>,
/// Whether this is a pre-warm snapshot rather than live data, when present.
pub snapshot: Option<bool>,
}

/// One element of a `LiveOrders` response.
#[derive(Debug, Clone, Deserialize)]
pub struct LiveOrder {
/// Account id, when present.
pub acct: Option<String>,
/// Contract id, when present.
pub conid: Option<i64>,
/// Order id (integer; `camelCase` `orderId` on this endpoint).
#[serde(rename = "orderId")]
pub order_id: Option<i64>,
/// Ticker symbol, when present.
pub ticker: Option<String>,
/// Side token, when present.
pub side: Option<String>,
/// Order status, when present.
pub status: Option<String>,
/// Order type (`camelCase` `orderType` on this endpoint), when present.
#[serde(rename = "orderType")]
pub order_type: Option<String>,
/// Total order size (`camelCase` `totalSize`), when present.
#[serde(rename = "totalSize")]
pub total_size: Option<serde_json::Number>,
/// Limit price, sent as a string on this endpoint, when present.
pub price: Option<String>,
}
37 changes: 36 additions & 1 deletion crates/adapter/ibkr/tests/endpoint.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! Endpoint path-rendering tests — one assertion group per constructor, covering
//! all seven Client Portal API v1 read-path endpoints.
//! the Client Portal API v1 read-path and order write-path endpoints.
use oath_adapter_ibkr::cpapi::{Endpoint, Method};

#[test]
Expand Down Expand Up @@ -52,3 +52,38 @@ fn secdef_info_path_interpolates_conid_only() {
assert_eq!(ep.method, Method::Get);
assert_eq!(ep.path, "/iserver/secdef/info?conid=265598");
}

#[test]
fn place_orders_is_a_post_with_account_in_path() {
let ep = Endpoint::place_orders("DU0000000");
assert_eq!(ep.method, Method::Post);
assert_eq!(ep.path, "/iserver/account/DU0000000/orders");
}

#[test]
fn reply_interpolates_the_reply_id() {
let ep = Endpoint::reply("a1b2c3d4-0000");
assert_eq!(ep.method, Method::Post);
assert_eq!(ep.path, "/iserver/reply/a1b2c3d4-0000");
}

#[test]
fn cancel_order_is_a_delete() {
let ep = Endpoint::cancel_order("DU0000000", "1234567890");
assert_eq!(ep.method, Method::Delete);
assert_eq!(ep.path, "/iserver/account/DU0000000/order/1234567890");
}

#[test]
fn order_status_interpolates_the_order_id() {
let ep = Endpoint::order_status("1234567890");
assert_eq!(ep.method, Method::Get);
assert_eq!(ep.path, "/iserver/account/order/status/1234567890");
}

#[test]
fn live_orders_is_a_get() {
let ep = Endpoint::live_orders();
assert_eq!(ep.method, Method::Get);
assert_eq!(ep.path, "/iserver/account/orders");
}
37 changes: 37 additions & 0 deletions crates/adapter/ibkr/tests/fixtures/cpapi/live_orders.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"orders": [
{
"acct": "DU0000000",
"conidex": "265598",
"conid": 265598,
"account": "DU0000000",
"orderId": 1234567890,
"cashCcy": "USD",
"sizeAndFills": "0/1",
"orderDesc": "Buy 1 AAPL Limit 1.00, Day",
"description1": "AAPL",
"ticker": "AAPL",
"secType": "STK",
"listingExchange": "NASDAQ.NMS",
"remainingQuantity": 1.0,
"filledQuantity": 0.0,
"totalSize": 1.0,
"companyName": "APPLE INC",
"status": "PreSubmitted",
"order_ccp_status": "Submitted",
"outsideRTH": false,
"origOrderType": "LIMIT",
"supportsTaxOpt": "1",
"lastExecutionTime": "000000000000",
"orderType": "Limit",
"bgColor": "#FFFFFF",
"fgColor": "#0000CC",
"isEventTrading": "0",
"price": "1.00",
"timeInForce": "CLOSE",
"lastExecutionTime_r": 0,
"side": "BUY"
}
],
"snapshot": true
}
Loading