Skip to content

Commit e15a740

Browse files
authored
fix: IBKR portfolio data (#324)
Loading the portfolio data from IBKR sometimes produced incomplete information. This fix guarantees the data is properly loaded, which mainly fixes data about ETFs and countries.
1 parent 3af2fde commit e15a740

File tree

5 files changed

+16
-13
lines changed

5 files changed

+16
-13
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ _This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) a
2222
- Fixed error aggregating numeric types
2323
- Fixed error sorting dates
2424
- DEGIRO: Prevent authenticated users from being redirected to 2FA login screen
25-
- IBKR: Handle incomplete IBKR API contract data with defensive fallbacks
25+
- IBKR:
26+
- Handle incomplete IBKR API contract data with defensive fallbacks
27+
- Fixed portfolio API import. Improves identification of ETFs
2628

2729
### Security
2830

docs/IBKR.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -439,18 +439,16 @@ The database model is defined in:
439439
- **Fee Information**: Limited fee data available
440440
- **Historical Data**: Depends on your account data subscription
441441

442-
#### Incomplete Position Contract Data
442+
#### Position Contract Data — Gateway Cache
443443

444-
The IBKR Web API `/portfolio` endpoints frequently return incomplete contract details. Fields like `ticker`, `name`, `sector`, `listingExchange`, and `countryCode` are often `None`.
444+
The IBKR Client Portal Gateway caches position data server-side. On a cold session (fresh gateway start), contract detail fields like `ticker`, `name`, `sector`, `type`, `listingExchange`, and `countryCode` may be missing from the first response of the `GET /portfolio/{accountId}/positions/{pageId}` endpoint.
445445

446-
**Workaround**: Stonks Overwatch uses `contractDesc` (which contains the ticker) as a fallback when these fields are missing.
446+
**Workaround**: Stonks Overwatch calls the positions endpoint twice — the first call primes the gateway cache with full contract details, and the second call returns the fully populated data. Defensive fallbacks are also in place for any fields that remain `None` (e.g., ETFs with no sector classification).
447447

448448
**Impact**:
449-
- ✅ Portfolio displays correctly with ticker symbols
449+
- ✅ Portfolio displays correctly with ticker symbols, sector, and asset type
450450
- ✅ Position values and P&L calculations work properly
451-
- ⚠️ Sector/geographic diversification may show generic values ("Unknown", "US")
452-
453-
These are IBKR API limitations, not application limitations. The application handles them gracefully with defensive fallback logic.
451+
- ⚠️ ETFs may still show `null` for `sector`/`group` as IBKR does not classify them
454452

455453
---
456454

src/stonks_overwatch/services/brokers/ibkr/client/ibkr_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def get_account_summary(self) -> dict:
119119

120120
def get_open_positions(self) -> list[dict]:
121121
self.logger.debug("Open Positions")
122+
# First call primes the gateway cache with full contract details (sector, type, etc.)
123+
self.client.positions(self.account.account_id)
122124
return self.client.positions(self.account.account_id).data
123125

124126
def transaction_history(self, conid: str, currency: str) -> dict:

src/stonks_overwatch/services/brokers/ibkr/services/portfolio.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,9 @@ def __create_portfolio_entry(self, position: dict, base_currency: str) -> Portfo
100100
"""
101101
Create a portfolio entry from IBKR position data.
102102
103-
Note: IBKR API often returns None for ticker, name, sector, etc.
104-
Uses contractDesc as fallback. See docs/IBKR.md for details.
103+
Note: Some fields (sector, group) are genuinely None for ETFs.
104+
Others (ticker, name, type, etc.) are populated after the gateway cache warms up.
105+
Defensive fallbacks handle both cases. See docs/IBKR.md for details.
105106
106107
Args:
107108
position: Position data from IBKR (may contain None values)
@@ -129,7 +130,7 @@ def __create_portfolio_entry(self, position: dict, base_currency: str) -> Portfo
129130

130131
is_open = position["position"] > 0
131132

132-
# Defensive handling: IBKR API limitation - use contractDesc as fallback
133+
# Defensive handling: ETFs have null sector/group; use contractDesc as ticker fallback
133134
ticker = position.get("ticker") or position.get("contractDesc") or "UNKNOWN"
134135
name = position.get("name") or position.get("contractDesc") or ticker
135136
sector_str = position.get("sector") or "Unknown"

src/stonks_overwatch/services/brokers/ibkr/services/update_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ def __import_open_positions(self, open_positions: list[dict]) -> None:
126126
"""
127127
Store open positions into the DB.
128128
129-
Note: IBKR API often returns None for ticker, name, sector, etc.
129+
Note: ETFs have null sector/group from the IBKR API.
130130
Portfolio service handles this with defensive fallbacks.
131-
See docs/IBKR.md (Known API Limitations) for details.
131+
See docs/IBKR.md for details.
132132
"""
133133
for row in open_positions:
134134
try:

0 commit comments

Comments
 (0)