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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
- **`oath-adapter-ibkr` — real paper-gateway fixtures + live-wire reconciliation.**
Replaced the representative CP API v1 fixtures with **real, sanitized** captures from a
live paper Client Portal Gateway, reconciling the DTOs, endpoint descriptor, and capture
script to what the wire actually sends: `tickle` binds IBKR's misspelled `collission`
key; `secdef/info` binds `ticker` (not `symbol`) and returns a single object for one
`conid`; `Endpoint::secdef_info` is now `conid`-only (a stock lookup with `secType=STK`
yields `400 "month required"`); and `capture.sh` sends an empty-body `POST /tickle`
(a bodyless POST returns `411 Length Required`).
- **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
3 changes: 3 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ ssodh = "ssodh"
acct = "acct"
mkt = "mkt"
hmds = "hmds"
# IBKR misspells "collision" as "collission" on the tickle response wire; we bind
# the wire key verbatim (see crates/adapter/ibkr/src/cpapi/auth.rs), so allow it.
collission = "collission"
6 changes: 4 additions & 2 deletions crates/adapter/ibkr/src/cpapi/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ pub struct TickleResponse {
/// milliseconds), when present.
#[serde(rename = "ssoExpires")]
pub sso_expires: Option<i64>,
/// `true` when a session collision occurred.
#[serde(default)]
/// `true` when a session collision occurred. IBKR misspells the wire key as
/// `collission` (verified against a live paper gateway); bind that exact key,
/// while still accepting the correctly-spelled key if IBKR ever fixes it.
#[serde(rename = "collission", alias = "collision", default)]
pub collision: bool,
/// Numeric user id, when present.
#[serde(rename = "userId")]
Expand Down
11 changes: 7 additions & 4 deletions crates/adapter/ibkr/src/cpapi/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,16 @@ impl Endpoint {
}
}

/// `GET /iserver/secdef/info` — contract details (call after `secdef_search`).
/// `conid` and `sec_type` are required query params.
/// `GET /iserver/secdef/info?conid={conid}` — contract details for a stock
/// (call after `secdef_search`). Only `conid` is sent: a live paper gateway
/// rejects `secType=STK` here with `400 "month required"`, because the `secType`
/// path is for options/futures (which additionally need `month`/`strike`/`right`
/// — a future slice).
#[must_use]
pub fn secdef_info(conid: i64, sec_type: &str) -> Self {
pub fn secdef_info(conid: i64) -> Self {
Self {
method: Method::Get,
path: format!("/iserver/secdef/info?conid={conid}&secType={sec_type}"),
path: format!("/iserver/secdef/info?conid={conid}"),
}
}
}
7 changes: 5 additions & 2 deletions crates/adapter/ibkr/src/cpapi/secdef.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ pub struct SecdefSearchEntry {
pub struct SecdefInfo {
/// IBKR contract id (an integer on this endpoint).
pub conid: i64,
/// Symbol, when present.
pub symbol: Option<String>,
/// Ticker symbol. IBKR names this field `ticker` on `secdef/info` (contrast the
/// `symbol` key on `secdef/search`); verified against a live paper gateway. The
/// `symbol` alias is accepted defensively.
#[serde(rename = "ticker", alias = "symbol")]
pub ticker: Option<String>,
/// Security type, when present.
#[serde(rename = "secType")]
pub sec_type: Option<String>,
Expand Down
8 changes: 8 additions & 0 deletions crates/adapter/ibkr/tests/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,11 @@ fn tickle_relays_iserver_auth_status() {
let iserver = tickle.iserver.expect("tickle relays the iserver block");
assert!(iserver.auth_status.authenticated);
}

#[test]
fn tickle_binds_ibkrs_misspelled_collission_key() {
// IBKR sends the collision flag under the misspelled key `collission`; ensure
// our rename binds it (a correctly-spelled key would silently default to false).
let tickle: TickleResponse = decode(br#"{"session":"s","collission":true}"#).expect("decodes");
assert!(tickle.collision);
}
8 changes: 5 additions & 3 deletions crates/adapter/ibkr/tests/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ fn secdef_search_is_a_post_to_iserver_secdef_search() {
}

#[test]
fn secdef_info_path_interpolates_conid_and_sec_type() {
let ep = Endpoint::secdef_info(265_598, "STK");
fn secdef_info_path_interpolates_conid_only() {
// A live paper gateway rejects `secType=STK` on this endpoint with
// 400 "month required"; a stock lookup passes `conid` alone.
let ep = Endpoint::secdef_info(265_598);
assert_eq!(ep.method, Method::Get);
assert_eq!(ep.path, "/iserver/secdef/info?conid=265598&secType=STK");
assert_eq!(ep.path, "/iserver/secdef/info?conid=265598");
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"authenticated":true,"competing":false,"connected":true,"message":"","MAC":"00:00:00:00:00:00","serverInfo":{"serverName":"JifN00000","serverVersion":"Build 10.25.0"},"fail":""}
{"authenticated":true,"established":true,"competing":false,"connected":true,"message":"","MAC":"00:00:00:00:00:00","serverInfo":{"serverName":"Jif000000","serverVersion":"Build 10.46.1q, Jul 2, 2026 3:35:33 PM"},"hardware_info":"00000000|00:00:00:00:00:00","fail":""}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"accounts":["DU0000000"],"selectedAccount":"DU0000000","isPaper":true}
{"accounts":["DU0000000"],"acctProps":{"DU0000000":{"hasChildAccounts":false,"supportsCashQty":true,"liteUnderPro":false,"noFXConv":false,"isProp":false,"supportsFractions":true,"allowCustomerTime":false,"autoFx":false}},"aliases":{"DU0000000":"DU0000000"},"allowFeatures":{"showGFIS":true,"showEUCostReport":false,"allowEventContract":true,"allowFXConv":true,"allowFinancialLens":false,"allowMTA":true,"allowTypeAhead":true,"allowEventTrading":true,"snapshotRefreshTimeout":30,"liteUser":false,"showWebNews":true,"research":true,"debugPnl":true,"showTaxOpt":true,"showImpactDashboard":true,"allowDynAccount":false,"allowCrypto":false,"allowFA":false,"allowLiteUnderPro":false,"allowedAssetTypes":"STK,CFD,OPT,FOP,WAR,FUT,BAG,PDC,CASH,IND,BOND,BILL,FUND,SLB,News,CMDTY,IOPT,ICU,ICS,PHYSS,CRYPTO","restrictTradeSubscription":false,"showUkUserLabels":false,"sideBySide":true},"chartPeriods":{"STK":["*"],"CFD":["*"],"OPT":["2h","1d","2d","1w","1m"],"FOP":["2h","1d","2d","1w","1m"],"WAR":["*"],"IOPT":["*"],"FUT":["*"],"CASH":["*"],"IND":["*"],"BOND":["*"],"FUND":["*"],"CMDTY":["*"],"PHYSS":["*"],"CRYPTO":["*"]},"groups":[],"profiles":[],"selectedAccount":"DU0000000","serverInfo":{"serverName":"Jif000000","serverVersion":"Build 10.46.1q, Jul 2, 2026 3:35:33 PM"},"sessionId":"00000000.00000000","isFT":false,"isPaper":true}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"id":"DU0000000","accountId":"DU0000000","currency":"USD","type":"DEMO","displayName":"Paper"}]
[{"id":"DU0000000","PrepaidCrypto-Z":false,"PrepaidCrypto-P":false,"brokerageAccess":true,"accountId":"DU0000000","accountVan":"DU0000000","accountTitle":"Test User","displayName":"Test User","accountAlias":null,"accountStatus":0,"currency":"EUR","type":"DEMO","tradingType":"STKNOPT","businessType":"INDEPENDENT","category":"","ibEntity":"IB-IE","faclient":false,"clearingStatus":"O","covestor":false,"noClientTrading":false,"trackVirtualFXPortfolio":false,"acctCustType":"INDIVIDUAL","parent":{"mmc":[],"accountId":"","isMParent":false,"isMChild":false,"isMultiplex":false},"desc":"DU0000000"}]
2 changes: 1 addition & 1 deletion crates/adapter/ibkr/tests/fixtures/cpapi/positions.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"acctId":"DU0000000","conid":265598,"contractDesc":"AAPL","position":100,"mktPrice":150.25,"mktValue":15025.0,"currency":"USD","assetClass":"STK"}]
[{"acctId":"DU0000000","conid":265598,"contractDesc":"AAPL","position":0.0,"mktPrice":314.341156,"mktValue":0.0,"currency":"USD","avgCost":0.0,"avgPrice":0.0,"realizedPnl":0.0,"unrealizedPnl":0.0,"exchs":null,"expiry":null,"putOrCall":null,"multiplier":null,"strike":0.0,"exerciseStyle":null,"conExchMap":[],"assetClass":"STK","undConid":0,"model":""}]
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"conid":265598,"symbol":"AAPL","secType":"STK","exchange":"NASDAQ","currency":"USD","companyName":"APPLE INC"}]
{"conid":265598,"ticker":"AAPL","secType":"STK","listingExchange":"NASDAQ.NMS","exchange":"SMART","companyName":"APPLE INC","currency":"USD","validExchanges":"SMART,AMEX,NYSE,CBOE,PHLX,ISE,CHX,ARCA,NASDAQ,DRCTEDGE,BEX,BATS,EDGEA,BYX,IEX,EDGX,FOXRIVER,PEARL,NYSENAT,LTSE,MEMX,IBEOS,OVERNIGHT,TPLUS0,PSX,T24X","priceRendering":null,"maturityDate":null,"right":"?","strike":0.0}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"conid":"265598","companyName":"APPLE INC","symbol":"AAPL","description":"NASDAQ","sections":[{"secType":"STK"},{"secType":"OPT","months":"JAN26;FEB26"}]}]
[{"conid":"265598","companyHeader":"APPLE INC - NASDAQ","companyName":"APPLE INC","symbol":"AAPL","description":"NASDAQ","restricted":null,"sections":[{"secType":"STK"},{"secType":"OPT","months":"JUL26;AUG26;SEP26;OCT26;NOV26;DEC26;JAN27;FEB27;MAR27;JUN27;SEP27;DEC27;JAN28;MAR28;DEC28","exchange":"SMART;AMEX;BATS;BOX;CBOE;CBOE2;EDGX;EMERALD;GEMINI;IBUSOPT;ISE;MEMX;MERCURY;MIAX;NASDAQBX;NASDAQOM;PEARL;PHLX;PSE;SAPPHIRE"},{"secType":"WAR","months":"JUN26;JUL26;AUG26;SEP26;OCT26;NOV26;DEC26;JAN27;FEB27;MAR27;JUN27;SEP27;DEC27;JAN28;MAR28;JUN28;SEP28;DEC28;JAN29;JUN29;DEC29;JAN30","exchange":"BVME;EBS;FWB;GETTEX;SBF;SEHK;SWB"},{"secType":"IOPT"},{"secType":"CFD","exchange":"SMART","conid":"120549942"},{"secType":"BAG"}]},{"conid":"532640894","companyHeader":"APPLE INC-CDR - TSE","companyName":"APPLE INC-CDR","symbol":"AAPL","description":"TSE","restricted":null,"sections":[{"secType":"STK"},{"secType":"OPT","months":"JUL26;AUG26;SEP26;OCT26;NOV26;DEC26;MAR27;JUN27","exchange":"CDE"},{"secType":"BAG"}]},{"conid":"38708077","companyHeader":"APPLE INC - MEXI","companyName":"APPLE INC","symbol":"AAPL","description":"MEXI","restricted":null,"sections":[{"secType":"STK","exchange":"MEXI;"},{"secType":"FUT","months":"SEP26;DEC26;MAR27;JUN27","exchange":"MEXDER","showPrips":true},{"secType":"BAG","exchange":"MEXDER","legSecType":"FUT"}]},{"conid":"273982664","companyHeader":"APPLE INC - EBS","companyName":"APPLE INC","symbol":"AAPL","description":"EBS","restricted":null,"sections":[{"secType":"STK"}]},{"issuers":[{"id":"e1432232","name":"Apple Inc"}],"bondid":4,"conid":"2147483647","companyHeader":"Corporate Fixed Income","companyName":null,"symbol":null,"description":null,"restricted":null,"sections":[{"secType":"BOND"}]}]
2 changes: 1 addition & 1 deletion crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json
Original file line number Diff line number Diff line change
@@ -1 +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":""}}}
{"session":"00000000000000000000000000000000","ssoExpires":248990,"collission":false,"userId":0,"hmds":{"error":"no bridge"},"iserver":{"authStatus":{"authenticated":true,"established":true,"competing":false,"connected":true,"message":"","MAC":"00:00:00:00:00:00","serverInfo":{"serverName":"Jif000000","serverVersion":"Build 10.46.1q, Jul 2, 2026 3:35:33 PM"},"hardware_info":"00000000|00:00:00:00:00:00"}}}
2 changes: 1 addition & 1 deletion crates/adapter/ibkr/tests/portfolio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ fn positions_deserialize_conid_as_int_and_money_as_number() {
// 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")
Some("314.341156")
);
}
13 changes: 8 additions & 5 deletions crates/adapter/ibkr/tests/secdef.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ fn secdef_search_conid_is_a_string() {
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);
// Real AAPL search: STK, OPT, WAR, IOPT, CFD, BAG.
assert_eq!(e.sections.len(), 6);
}

#[test]
fn secdef_info_conid_is_an_int() {
let infos: Vec<SecdefInfo> =
// For a single conid, secdef/info returns one JSON object (not an array).
let info: SecdefInfo =
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"));
assert_eq!(info.conid, 265_598);
assert_eq!(info.sec_type.as_deref(), Some("STK"));
// IBKR names the symbol field `ticker` on this endpoint.
assert_eq!(info.ticker.as_deref(), Some("AAPL"));
}
8 changes: 6 additions & 2 deletions docker/cpapi/capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ fetch() {
}

fetch GET /iserver/auth/status auth_status.json
fetch POST /tickle tickle.json
# POST /tickle with no body returns 411 (Length Required); send an empty body so
# curl sets Content-Length: 0.
curl -fksS --max-time 30 -X POST --data '' "$BASE/tickle" -o "$OUT/tickle.json"
echo "captured tickle.json"
fetch GET /iserver/accounts iserver_accounts.json
fetch GET /portfolio/accounts portfolio_accounts.json

Expand All @@ -37,6 +40,7 @@ curl -fksS --max-time 30 -X POST "$BASE/iserver/secdef/search" \
-o "$OUT/secdef_search.json"
echo "captured secdef_search.json"

fetch GET "/iserver/secdef/info?conid=265598&secType=STK" secdef_info.json
# secType=STK triggers 400 "month required" on a stock; pass conid alone.
fetch GET "/iserver/secdef/info?conid=265598" secdef_info.json

echo "DONE. SANITIZE before committing: scrub account ids, balances, and names."