From ba21ab36fe9e8323ed6b7ca25614b766de000a83 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:53:18 +0000 Subject: [PATCH] fix(ibkr): reconcile CP API v1 read-path to the live paper gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the representative cpapi fixtures with real, sanitized captures from a live paper Client Portal Gateway, and reconcile the DTOs, endpoint descriptor, and capture script to what the wire actually sends: - tickle: bind IBKR's misspelled `collission` key (a correct-spelling alias is kept should IBKR ever fix it); add a guard test. - secdef/info: bind `ticker` (not `symbol`), and decode a single object (not an array) for one conid. - Endpoint::secdef_info is now conid-only — a stock lookup with `secType=STK` is rejected `400 "month required"`. - capture.sh: `POST /tickle` sends an empty body (a bodyless POST returns 411); secdef/info drops `secType`. - secdef/search: real AAPL returns 6 sections; positions keep money as `serde_json::Number` (real mktPrice preserved, holdings zeroed). Fixtures sanitized: account id, holder name, session token, userId, MAC, hardware id, balances, and account-status date scrubbed; the public market price is kept. `collission` is allowlisted in _typos.toml. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ _typos.toml | 3 +++ crates/adapter/ibkr/src/cpapi/auth.rs | 6 ++++-- crates/adapter/ibkr/src/cpapi/endpoint.rs | 11 +++++++---- crates/adapter/ibkr/src/cpapi/secdef.rs | 7 +++++-- crates/adapter/ibkr/tests/auth.rs | 8 ++++++++ crates/adapter/ibkr/tests/endpoint.rs | 8 +++++--- .../ibkr/tests/fixtures/cpapi/auth_status.json | 2 +- .../ibkr/tests/fixtures/cpapi/iserver_accounts.json | 2 +- .../tests/fixtures/cpapi/portfolio_accounts.json | 2 +- .../ibkr/tests/fixtures/cpapi/positions.json | 2 +- .../ibkr/tests/fixtures/cpapi/secdef_info.json | 2 +- .../ibkr/tests/fixtures/cpapi/secdef_search.json | 2 +- .../adapter/ibkr/tests/fixtures/cpapi/tickle.json | 2 +- crates/adapter/ibkr/tests/portfolio.rs | 2 +- crates/adapter/ibkr/tests/secdef.rs | 13 ++++++++----- docker/cpapi/capture.sh | 8 ++++++-- 17 files changed, 62 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b8658..600b0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/_typos.toml b/_typos.toml index e698c1f..a871a96 100644 --- a/_typos.toml +++ b/_typos.toml @@ -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" diff --git a/crates/adapter/ibkr/src/cpapi/auth.rs b/crates/adapter/ibkr/src/cpapi/auth.rs index 89d6dab..acda099 100644 --- a/crates/adapter/ibkr/src/cpapi/auth.rs +++ b/crates/adapter/ibkr/src/cpapi/auth.rs @@ -53,8 +53,10 @@ pub struct TickleResponse { /// milliseconds), when present. #[serde(rename = "ssoExpires")] pub sso_expires: Option, - /// `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")] diff --git a/crates/adapter/ibkr/src/cpapi/endpoint.rs b/crates/adapter/ibkr/src/cpapi/endpoint.rs index 66e1967..9697dec 100644 --- a/crates/adapter/ibkr/src/cpapi/endpoint.rs +++ b/crates/adapter/ibkr/src/cpapi/endpoint.rs @@ -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}"), } } } diff --git a/crates/adapter/ibkr/src/cpapi/secdef.rs b/crates/adapter/ibkr/src/cpapi/secdef.rs index 2b19d7d..69e84fe 100644 --- a/crates/adapter/ibkr/src/cpapi/secdef.rs +++ b/crates/adapter/ibkr/src/cpapi/secdef.rs @@ -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, + /// 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, /// Security type, when present. #[serde(rename = "secType")] pub sec_type: Option, diff --git a/crates/adapter/ibkr/tests/auth.rs b/crates/adapter/ibkr/tests/auth.rs index 80aed70..1dbd865 100644 --- a/crates/adapter/ibkr/tests/auth.rs +++ b/crates/adapter/ibkr/tests/auth.rs @@ -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); +} diff --git a/crates/adapter/ibkr/tests/endpoint.rs b/crates/adapter/ibkr/tests/endpoint.rs index a0dbdf2..411e70e 100644 --- a/crates/adapter/ibkr/tests/endpoint.rs +++ b/crates/adapter/ibkr/tests/endpoint.rs @@ -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"); } diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json b/crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json index d0e91a9..67eecbf 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/auth_status.json @@ -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":""} \ No newline at end of file diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json b/crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json index 34a7614..d08d67f 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/iserver_accounts.json @@ -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} \ No newline at end of file diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json b/crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json index 4cdebdb..aca1d9d 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/portfolio_accounts.json @@ -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"}] \ No newline at end of file diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/positions.json b/crates/adapter/ibkr/tests/fixtures/cpapi/positions.json index 8bcf7fc..ecbfe3a 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/positions.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/positions.json @@ -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":""}] \ No newline at end of file diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json index f69b133..0203b16 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_info.json @@ -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} \ No newline at end of file diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json index 535c87a..b2fe59b 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/secdef_search.json @@ -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"}]}] \ No newline at end of file diff --git a/crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json b/crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json index fa6015f..687f9da 100644 --- a/crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json +++ b/crates/adapter/ibkr/tests/fixtures/cpapi/tickle.json @@ -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"}}} \ No newline at end of file diff --git a/crates/adapter/ibkr/tests/portfolio.rs b/crates/adapter/ibkr/tests/portfolio.rs index 8cbaa4b..fade14f 100644 --- a/crates/adapter/ibkr/tests/portfolio.rs +++ b/crates/adapter/ibkr/tests/portfolio.rs @@ -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") ); } diff --git a/crates/adapter/ibkr/tests/secdef.rs b/crates/adapter/ibkr/tests/secdef.rs index eb2852e..a65120b 100644 --- a/crates/adapter/ibkr/tests/secdef.rs +++ b/crates/adapter/ibkr/tests/secdef.rs @@ -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 = + // 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")); } diff --git a/docker/cpapi/capture.sh b/docker/cpapi/capture.sh index 1a86cf7..bbb50ce 100755 --- a/docker/cpapi/capture.sh +++ b/docker/cpapi/capture.sh @@ -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 @@ -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."