broker-sync/docs/plans/2026-05-26-ibkr-flex-ingestion.md
Viktor Barzin 30af5fe2c9
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was canceled
docs(ibkr): change Flex date range from Last Business Day → Last 90 Days
Trailing window backed by SyncRecordStore dedup is strictly better than
a single-day window — a single missed cron run with Last Business Day
loses that day's activity permanently. SyncRecordStore is keyed by
ibkr:trade:<tradeID> / ibkr:cash:<transactionID>, so overlapping pulls
are no-ops.

Caught during the brainstorming review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:11:57 +00:00

1580 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# IBKR Flex Ingestion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a daily IBKR Flex Web Service → Wealthfolio ingestion path
to `broker-sync`, with mandatory broker-vs-WF position reconciliation.
**Architecture:** New `IBKRProvider` in `broker_sync/providers/ibkr.py` uses
the `ibflex` library to download + parse Flex XML reports. Mapped activities
flow through the existing pipeline (cash-flow-match → dedup → WF import).
After import, a new reconciliation step compares Flex `OpenPositions`
against WF-computed quantities and pushes drift to Pushgateway. A new K8s
CronJob `broker-sync-ibkr` schedules it at 02:00 UK daily.
**Tech stack:** Python 3.12, `ibflex ^0.16` (new), `httpx` (existing),
`typer` (existing), Terraform + K8s CronJob (existing pattern), Vault
KV-v2 secret backend (existing), Prometheus Pushgateway (cluster-internal).
**Spec:** `docs/specs/2026-05-26-ibkr-ingest-design.md` (in this repo).
---
## File Structure
| Path | Responsibility | New? |
|---|---|---|
| `broker_sync/providers/ibkr.py` | `IBKRProvider` — fetch + parse + map. Module is the entire IBKR ingestion provider. | NEW |
| `broker_sync/metrics.py` | One-function module: `push_pushgateway(job, metrics, labels)` — simple httpx POST to the cluster Pushgateway. Shared by future providers. | NEW |
| `broker_sync/sinks/wealthfolio.py` | Add `compute_position_qty(account_id) -> dict[str, Decimal]` method to `WealthfolioSink`. | MODIFY |
| `broker_sync/cli.py` | Add `@app.command("ibkr")` typer command, parallel to `trading212` and `invest-engine`. | MODIFY |
| `pyproject.toml` | Add `ibflex = "^0.16"` dependency. | MODIFY |
| `tests/providers/test_ibkr.py` | Unit tests for IBKRProvider mapping logic + account guard. | NEW |
| `tests/fixtures/ibkr/sample_flex.xml` | Canned Flex XML fixture (3 trades, 2 cash txns, 2 positions, 1 account). | NEW |
| `tests/sinks/test_wealthfolio.py` | Add tests for the new `compute_position_qty` method. | MODIFY |
| `tests/test_metrics.py` | Test the `push_pushgateway` function with a mock httpx transport. | NEW |
| `infra/stacks/broker-sync/main.tf` | Add `kubernetes_cron_job_v1.ibkr` resource + matching PrometheusRule for drift / staleness alerts. | MODIFY |
Files are split by responsibility, not by layer. The provider is a single
file (`ibkr.py`) because its three concerns — fetch, parse-map, reconcile
— are tightly coupled by the Flex XML shape.
---
## Task 1: Add the `ibflex` dependency
**Files:**
- Modify: `pyproject.toml`
- [ ] **Step 1: Add `ibflex` to dependencies**
In `pyproject.toml`, under `[tool.poetry.dependencies]`, add:
```toml
ibflex = "^0.16"
```
- [ ] **Step 2: Resolve + install**
```bash
cd /home/wizard/code/broker-sync && poetry lock --no-update && poetry install
```
Expected output: `Installing ibflex (0.16.x)`. No error.
- [ ] **Step 3: Verify it imports**
```bash
poetry run python -c "from ibflex import client, parser; print(client, parser)"
```
Expected: prints two module objects, no exception.
- [ ] **Step 4: Commit**
```bash
git add pyproject.toml poetry.lock
git commit -m "deps: add ibflex for IBKR Flex Web Service ingestion"
```
---
## Task 2: Fixture — canned Flex XML
**Files:**
- Create: `tests/fixtures/ibkr/sample_flex.xml`
- [ ] **Step 1: Create the fixture directory**
```bash
mkdir -p /home/wizard/code/broker-sync/tests/fixtures/ibkr
```
- [ ] **Step 2: Write the fixture file**
Create `tests/fixtures/ibkr/sample_flex.xml`:
```xml
<?xml version="1.0" encoding="UTF-8" ?>
<FlexQueryResponse queryName="broker-sync-activity" type="AF">
<FlexStatements count="1">
<FlexStatement accountId="U12345678" fromDate="2026-05-20" toDate="2026-05-26" whenGenerated="2026-05-26T02:00:00">
<AccountInformation accountId="U12345678" acctAlias="" currency="GBP" name="Viktor Test" accountType="Individual"/>
<Trades>
<Trade tradeID="T1001" tradeDate="2026-05-21" tradeTime="14:30:00 BST" symbol="VUAG" buySell="BUY" quantity="10" tradePrice="107.50" currency="GBP" ibCommission="-1.05" assetCategory="ETF"/>
<Trade tradeID="T1002" tradeDate="2026-05-22" tradeTime="09:15:00 BST" symbol="AAPL" buySell="BUY" quantity="5" tradePrice="180.25" currency="USD" ibCommission="-0.50" assetCategory="STK"/>
<Trade tradeID="T1003" tradeDate="2026-05-23" tradeTime="11:00:00 BST" symbol="VUAG" buySell="SELL" quantity="2" tradePrice="108.00" currency="GBP" ibCommission="-0.30" assetCategory="ETF"/>
</Trades>
<CashTransactions>
<CashTransaction transactionID="C5001" dateTime="2026-05-22 12:00:00" type="Dividends" amount="3.50" currency="GBP" description="VUAG DIV"/>
<CashTransaction transactionID="C5002" dateTime="2026-05-22 12:00:00" type="Withholding Tax" amount="-0.35" currency="GBP" description="VUAG WHT"/>
</CashTransactions>
<OpenPositions>
<OpenPosition symbol="VUAG" position="8" markPrice="108.20" currency="GBP" assetCategory="ETF"/>
<OpenPosition symbol="AAPL" position="5" markPrice="181.00" currency="USD" assetCategory="STK"/>
</OpenPositions>
</FlexStatement>
</FlexStatements>
</FlexQueryResponse>
```
- [ ] **Step 3: Verify ibflex can parse it**
```bash
cd /home/wizard/code/broker-sync && poetry run python -c "
from ibflex import parser
r = parser.parse('tests/fixtures/ibkr/sample_flex.xml')
s = r.FlexStatements[0]
assert s.accountId == 'U12345678'
assert len(s.Trades) == 3
assert len(s.CashTransactions) == 2
assert len(s.OpenPositions) == 2
print('OK')
"
```
Expected: prints `OK`.
- [ ] **Step 4: Commit**
```bash
git add tests/fixtures/ibkr/sample_flex.xml
git commit -m "test: add IBKR Flex XML fixture for provider tests"
```
---
## Task 3: `metrics.py` — Pushgateway client + test
**Files:**
- Create: `broker_sync/metrics.py`
- Create: `tests/test_metrics.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_metrics.py`:
```python
from __future__ import annotations
import httpx
import pytest
from broker_sync.metrics import push_pushgateway
@pytest.mark.asyncio
async def test_push_pushgateway_posts_text_format() -> None:
captured: dict[str, object] = {}
def transport_handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["method"] = request.method
captured["body"] = request.content.decode("utf-8")
return httpx.Response(200)
transport = httpx.MockTransport(transport_handler)
await push_pushgateway(
job="broker-sync-ibkr",
metrics=[
("ibkr_position_drift_shares", {"symbol": "VUAG.L"}, 0.0),
("ibkr_sync_last_success_timestamp_seconds", {}, 1779830000.0),
],
pushgateway_url="http://pg.example/metrics",
transport=transport,
)
assert captured["method"] == "POST"
assert captured["url"] == "http://pg.example/metrics/job/broker-sync-ibkr"
body = captured["body"]
assert 'ibkr_position_drift_shares{symbol="VUAG.L"} 0.0' in body
assert "ibkr_sync_last_success_timestamp_seconds 1779830000.0" in body
@pytest.mark.asyncio
async def test_push_pushgateway_raises_on_non_2xx() -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(500, text="boom"))
with pytest.raises(RuntimeError, match="pushgateway.*500"):
await push_pushgateway(
job="x",
metrics=[("m", {}, 1.0)],
pushgateway_url="http://pg/metrics",
transport=transport,
)
```
- [ ] **Step 2: Run the test and verify it fails**
```bash
cd /home/wizard/code/broker-sync && poetry run pytest tests/test_metrics.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'broker_sync.metrics'`.
- [ ] **Step 3: Write the implementation**
Create `broker_sync/metrics.py`:
```python
"""Pushgateway client for broker-sync providers.
One function: push a list of (metric, labels, value) tuples to Prometheus
Pushgateway under a given job name. Used by providers to surface
per-run drift / staleness / row counts that Prometheus can alert on.
In-cluster URL: http://prometheus-prometheus-pushgateway.monitoring:9091/metrics
Pass that via the ``pushgateway_url`` env-driven argument.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Iterable
import httpx
log = logging.getLogger(__name__)
def _format_metric(name: str, labels: dict[str, str], value: float) -> str:
if labels:
body = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
return f"{name}{{{body}}} {value}\n"
return f"{name} {value}\n"
async def push_pushgateway(
job: str,
metrics: Iterable[tuple[str, dict[str, str], float]],
pushgateway_url: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
"""POST text-format metrics to Pushgateway under ``job``.
``pushgateway_url`` defaults to the env var ``PUSHGATEWAY_URL``.
Raises ``RuntimeError`` if the URL is unset or the POST returns non-2xx.
"""
url = pushgateway_url or os.environ.get("PUSHGATEWAY_URL")
if not url:
raise RuntimeError("PUSHGATEWAY_URL not set and no override provided")
body = "".join(_format_metric(name, labels, value) for name, labels, value in metrics)
target = f"{url.rstrip('/')}/job/{job}"
async with httpx.AsyncClient(transport=transport, timeout=15.0) as c:
resp = await c.post(target, content=body, headers={"Content-Type": "text/plain"})
if resp.status_code >= 300:
raise RuntimeError(
f"pushgateway POST {target} returned HTTP {resp.status_code}: {resp.text[:200]}"
)
log.info("pushgateway: pushed %d metrics to job=%s", len(body.splitlines()), job)
```
- [ ] **Step 4: Run the tests and verify they pass**
```bash
poetry run pytest tests/test_metrics.py -v
```
Expected: both tests pass.
- [ ] **Step 5: Type + lint check**
```bash
poetry run mypy broker_sync/metrics.py && poetry run ruff check broker_sync/metrics.py tests/test_metrics.py
```
Expected: both clean.
- [ ] **Step 6: Commit**
```bash
git add broker_sync/metrics.py tests/test_metrics.py
git commit -m "metrics: add Pushgateway client for broker-sync providers"
```
---
## Task 4: `WealthfolioSink.compute_position_qty` — and tests
**Files:**
- Modify: `broker_sync/sinks/wealthfolio.py`
- Modify: `tests/sinks/test_wealthfolio.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/sinks/test_wealthfolio.py`:
```python
@pytest.mark.asyncio
async def test_compute_position_qty_sums_buys_minus_sells(monkeypatch: MonkeyPatch) -> None:
"""compute_position_qty groups activities by symbol and returns
BUY/ADD_HOLDING/TRANSFER_IN minus SELL/REMOVE_HOLDING/TRANSFER_OUT
quantities as Decimal."""
from broker_sync.sinks.wealthfolio import WealthfolioSink
fake_activities = [
# symbol VUAG.L: 10 buys, 2 sells, net 8
{"symbol": "VUAG.L", "activityType": "BUY", "quantity": "10"},
{"symbol": "VUAG.L", "activityType": "SELL", "quantity": "2"},
# symbol AAPL: 5 buys
{"symbol": "AAPL", "activityType": "BUY", "quantity": "5"},
# cash activities (no asset) — skipped
{"symbol": "$CASH-GBP", "activityType": "DEPOSIT", "quantity": "0", "amount": "100"},
]
sink = WealthfolioSink(base_url="http://wf", username="u", password="p", session_path="/tmp/s")
async def fake_search(account_id: str, page: int) -> dict:
return {"activities": fake_activities if page == 1 else [], "totalPages": 1}
monkeypatch.setattr(sink, "_search_activities", fake_search)
result = await sink.compute_position_qty("acct-123")
assert result == {"VUAG.L": Decimal("8"), "AAPL": Decimal("5")}
```
Add the `Decimal` import at the top of the test module if missing:
```python
from decimal import Decimal
```
- [ ] **Step 2: Run the test and verify it fails**
```bash
poetry run pytest tests/sinks/test_wealthfolio.py::test_compute_position_qty_sums_buys_minus_sells -v
```
Expected: FAIL with `AttributeError: 'WealthfolioSink' object has no attribute 'compute_position_qty'` (or similar — `_search_activities` may also be missing).
- [ ] **Step 3: Add the method to WealthfolioSink**
In `broker_sync/sinks/wealthfolio.py`, inside the `WealthfolioSink` class, add (alongside the existing methods):
```python
async def _search_activities(self, account_id: str, page: int) -> dict[str, Any]:
"""Internal: one page of /activities/search results for an account."""
resp = await self._request(
"POST",
"/api/v1/activities/search",
json={"accountIds": [account_id], "page": page, "pageSize": 500},
)
resp.raise_for_status()
return resp.json() # type: ignore[no-any-return]
async def compute_position_qty(self, account_id: str) -> dict[str, Decimal]:
"""Return per-symbol net position quantity (BUY/IN minus SELL/OUT) for
one account. Skips cash activities. Used by the IBKR reconciliation
step to compare against broker-reported OpenPositions."""
qty_by_symbol: dict[str, Decimal] = {}
page = 1
while True:
payload = await self._search_activities(account_id, page)
activities = payload.get("activities", [])
if not activities:
break
for act in activities:
symbol = act.get("symbol")
if not symbol or symbol.startswith("$CASH"):
continue
act_type = act.get("activityType")
sign: int
if act_type in {"BUY", "ADD_HOLDING", "TRANSFER_IN"}:
sign = 1
elif act_type in {"SELL", "REMOVE_HOLDING", "TRANSFER_OUT"}:
sign = -1
else:
continue
qty = Decimal(str(act.get("quantity") or 0))
qty_by_symbol[symbol] = qty_by_symbol.get(symbol, Decimal(0)) + sign * qty
if page >= int(payload.get("totalPages") or 1):
break
page += 1
return qty_by_symbol
```
Add the `Decimal` import at the top of `wealthfolio.py` if missing:
```python
from decimal import Decimal
```
- [ ] **Step 4: Run the test and verify it passes**
```bash
poetry run pytest tests/sinks/test_wealthfolio.py::test_compute_position_qty_sums_buys_minus_sells -v
```
Expected: PASS.
- [ ] **Step 5: Run mypy + ruff + full pytest**
```bash
poetry run mypy broker_sync tests && poetry run ruff check . && poetry run pytest -q
```
Expected: all clean.
- [ ] **Step 6: Commit**
```bash
git add broker_sync/sinks/wealthfolio.py tests/sinks/test_wealthfolio.py
git commit -m "wealthfolio: add compute_position_qty for broker reconciliation"
```
---
## Task 5: `providers/ibkr.py` — symbol canonicalisation
**Files:**
- Create: `broker_sync/providers/ibkr.py`
- Create: `tests/providers/test_ibkr.py`
- [ ] **Step 1: Write the failing test**
Create `tests/providers/test_ibkr.py`:
```python
from __future__ import annotations
import pytest
from broker_sync.providers.ibkr import canonical_symbol
def test_canonical_symbol_lse_etf_gets_l_suffix() -> None:
assert canonical_symbol("VUAG", exchange="LSE", currency="GBP") == "VUAG.L"
def test_canonical_symbol_us_stock_unchanged() -> None:
assert canonical_symbol("AAPL", exchange="NASDAQ", currency="USD") == "AAPL"
def test_canonical_symbol_lse_gbp_inferred_when_exchange_missing() -> None:
"""IBKR Flex sometimes omits exchange. Infer LSE from currency==GBP."""
assert canonical_symbol("VUAG", exchange=None, currency="GBP") == "VUAG.L"
def test_canonical_symbol_already_suffixed_unchanged() -> None:
assert canonical_symbol("VUAG.L", exchange="LSE", currency="GBP") == "VUAG.L"
```
- [ ] **Step 2: Run the test and verify it fails**
```bash
poetry run pytest tests/providers/test_ibkr.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'broker_sync.providers.ibkr'`.
- [ ] **Step 3: Create the provider module with `canonical_symbol`**
Create `broker_sync/providers/ibkr.py`:
```python
"""Interactive Brokers Flex Web Service ingestion provider.
Pulls daily Activity Flex Query reports via the ``ibflex`` library, maps
Trades + CashTransactions to broker-sync ``Activity`` objects, and runs a
reconciliation step against the broker-reported ``OpenPositions``.
See ``docs/specs/2026-05-26-ibkr-ingest-design.md`` for the full design.
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import TYPE_CHECKING
from broker_sync.models import Account, AccountType, Activity, ActivityType
if TYPE_CHECKING:
from ibflex import FlexQueryResponse
log = logging.getLogger(__name__)
# Map IBKR currency -> default exchange suffix.
# Only set up for the GBP / LSE case today; extend when more accounts onboard.
_CURRENCY_TO_LSE_SUFFIX = {"GBP": ".L"}
def canonical_symbol(symbol: str, *, exchange: str | None, currency: str) -> str:
"""Return the WF-canonical form of an IBKR ticker.
LSE-listed GBP instruments get a ``.L`` suffix (Wealthfolio convention).
US instruments and anything already suffixed are returned unchanged.
"""
if "." in symbol:
return symbol
if exchange in {"LSE", "LSEETF", "LSEIOB1"} or (
exchange is None and currency in _CURRENCY_TO_LSE_SUFFIX
):
return symbol + _CURRENCY_TO_LSE_SUFFIX.get(currency, ".L")
return symbol
```
- [ ] **Step 4: Run the test and verify it passes**
```bash
poetry run pytest tests/providers/test_ibkr.py -v
```
Expected: 4 tests PASS.
- [ ] **Step 5: Type + lint**
```bash
poetry run mypy broker_sync/providers/ibkr.py tests/providers/test_ibkr.py && poetry run ruff check broker_sync/providers/ibkr.py tests/providers/test_ibkr.py
```
Expected: clean.
- [ ] **Step 6: Commit**
```bash
git add broker_sync/providers/ibkr.py tests/providers/test_ibkr.py
git commit -m "ibkr: add canonical_symbol helper (LSE .L suffix handling)"
```
---
## Task 6: `_map_trade_to_activity`
**Files:**
- Modify: `broker_sync/providers/ibkr.py`
- Modify: `tests/providers/test_ibkr.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/providers/test_ibkr.py`:
```python
def test_map_trade_buy_to_activity() -> None:
"""Trade with buySell=BUY maps to Activity(activity_type=BUY) with
positive quantity, fee = abs(ibCommission), external_id = ibkr:trade:<tradeID>."""
from datetime import datetime
from decimal import Decimal
from broker_sync.providers.ibkr import _map_trade_to_activity
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
trade = r.FlexStatements[0].Trades[0] # T1001: 10 VUAG BUY @ 107.50 GBP
activity = _map_trade_to_activity(trade, account_id="wf-acct-uuid")
assert activity.external_id == "ibkr:trade:T1001"
assert activity.account_id == "wf-acct-uuid"
assert activity.activity_type == ActivityType.BUY
assert activity.symbol == "VUAG.L"
assert activity.quantity == Decimal("10")
assert activity.unit_price == Decimal("107.50")
assert activity.fee == Decimal("1.05")
assert activity.currency == "GBP"
assert isinstance(activity.date, datetime)
assert activity.date.tzinfo is not None
```
- [ ] **Step 2: Run and verify it fails**
```bash
poetry run pytest tests/providers/test_ibkr.py::test_map_trade_buy_to_activity -v
```
Expected: FAIL with `ImportError: cannot import name '_map_trade_to_activity'`.
- [ ] **Step 3: Add the mapper**
Append to `broker_sync/providers/ibkr.py`:
```python
from datetime import UTC, datetime # noqa: E402 (grouped here for the mapper section)
if TYPE_CHECKING:
from ibflex.Types import OpenPosition, Trade
from ibflex.Types import CashTransaction as IBFlexCashTransaction
def _trade_to_datetime(trade_date: object, trade_time: str | None) -> datetime:
"""Combine Flex tradeDate (a date) + tradeTime (HH:MM:SS TZ) into UTC datetime."""
if isinstance(trade_date, datetime):
# ibflex sometimes already returns datetime
dt = trade_date
else:
# date object
time_part = (trade_time or "00:00:00 UTC").split()[0]
dt = datetime.fromisoformat(f"{trade_date.isoformat()}T{time_part}")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)
def _map_trade_to_activity(trade: Trade, *, account_id: str) -> Activity:
"""Map one ibflex Trade dataclass to a broker-sync Activity."""
buy_sell = str(trade.buySell.name) if hasattr(trade.buySell, "name") else str(trade.buySell)
if buy_sell == "BUY":
activity_type = ActivityType.BUY
elif buy_sell == "SELL":
activity_type = ActivityType.SELL
else:
raise ValueError(f"unsupported Trade.buySell={buy_sell!r} on tradeID={trade.tradeID}")
symbol = canonical_symbol(
str(trade.symbol),
exchange=getattr(trade, "exchange", None),
currency=str(trade.currency),
)
quantity = abs(Decimal(str(trade.quantity)))
unit_price = Decimal(str(trade.tradePrice))
fee = abs(Decimal(str(trade.ibCommission or 0)))
return Activity(
external_id=f"ibkr:trade:{trade.tradeID}",
account_id=account_id,
account_type=AccountType.GIA,
date=_trade_to_datetime(trade.tradeDate, getattr(trade, "tradeTime", None)),
activity_type=activity_type,
currency=str(trade.currency),
symbol=symbol,
quantity=quantity,
unit_price=unit_price,
fee=fee,
)
```
Move the `from datetime import UTC, datetime` import to the top-level imports
section if your repo's lint rules forbid late imports — ruff's E402 is suppressed
here via `# noqa: E402` because grouping helps readability.
- [ ] **Step 4: Run the test and verify it passes**
```bash
poetry run pytest tests/providers/test_ibkr.py -v
```
Expected: all 5 tests pass.
- [ ] **Step 5: Type + lint**
```bash
poetry run mypy broker_sync/providers/ibkr.py && poetry run ruff check broker_sync/providers/ibkr.py
```
Expected: clean.
- [ ] **Step 6: Commit**
```bash
git add broker_sync/providers/ibkr.py tests/providers/test_ibkr.py
git commit -m "ibkr: map Flex Trades to broker-sync Activities"
```
---
## Task 7: `_map_cash_to_activity`
**Files:**
- Modify: `broker_sync/providers/ibkr.py`
- Modify: `tests/providers/test_ibkr.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/providers/test_ibkr.py`:
```python
def test_map_cash_dividend_to_activity() -> None:
from decimal import Decimal
from broker_sync.providers.ibkr import _map_cash_to_activity
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
cash = r.FlexStatements[0].CashTransactions[0] # C5001: Dividends 3.50 GBP
activity = _map_cash_to_activity(cash, account_id="wf-acct-uuid")
assert activity is not None
assert activity.external_id == "ibkr:cash:C5001"
assert activity.activity_type == ActivityType.DIVIDEND
assert activity.amount == Decimal("3.50")
assert activity.currency == "GBP"
def test_map_cash_withholding_tax_to_fee_activity() -> None:
from decimal import Decimal
from broker_sync.providers.ibkr import _map_cash_to_activity
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
cash = r.FlexStatements[0].CashTransactions[1] # C5002: Withholding Tax -0.35 GBP
activity = _map_cash_to_activity(cash, account_id="wf-acct-uuid")
assert activity is not None
assert activity.activity_type == ActivityType.FEE
assert activity.amount == Decimal("0.35") # always positive on Activity, sign carried by activity_type
def test_map_cash_unknown_type_returns_none_and_logs(caplog) -> None: # noqa: ANN001
"""Unknown CashTransaction.type produces None + a WARNING log line.
Same refusal-to-guess convention as the InvestEngine provider."""
from broker_sync.providers.ibkr import _map_cash_to_activity
class FakeCash:
transactionID = "C9999"
dateTime = None
type = type("T", (), {"name": "FrobnicatedThing"})()
amount = 0
currency = "GBP"
with caplog.at_level("WARNING"):
result = _map_cash_to_activity(FakeCash, account_id="wf-acct-uuid")
assert result is None
assert any("FrobnicatedThing" in r.message for r in caplog.records)
```
- [ ] **Step 2: Run and verify the new tests fail**
```bash
poetry run pytest tests/providers/test_ibkr.py -v
```
Expected: 3 FAILs (the new tests), 5 existing PASS.
- [ ] **Step 3: Add the cash mapper**
Append to `broker_sync/providers/ibkr.py`:
```python
# Maps the IBKR Flex CashTransaction.type values we expect to see for a
# stocks/ETFs-only GIA. Unknown values yield None + a WARNING — we refuse
# to guess (per IE/Schwab convention) to avoid silent misclassification.
_CASH_TYPE_MAP: dict[str, ActivityType] = {
"Dividends": ActivityType.DIVIDEND,
"Withholding Tax": ActivityType.FEE,
"Broker Interest Received": ActivityType.DIVIDEND,
"Broker Interest Paid": ActivityType.FEE,
"Commission Adjustments": ActivityType.FEE,
"Other Fees": ActivityType.FEE,
}
def _map_cash_to_activity(
cash: IBFlexCashTransaction, *, account_id: str
) -> Activity | None:
"""Map one ibflex CashTransaction to a broker-sync Activity.
Returns None for unsupported types (logged at WARNING). Deposit/Withdrawal
handled separately by sign of amount.
"""
type_obj = cash.type
type_name = type_obj.name if hasattr(type_obj, "name") else str(type_obj)
amount = Decimal(str(cash.amount))
# Deposit / Withdrawal split by sign — the Flex "Deposits & Withdrawals" type
if type_name in {"DepositsWithdrawals", "Deposits & Withdrawals", "Deposit Withdrawals"}:
activity_type = ActivityType.DEPOSIT if amount > 0 else ActivityType.WITHDRAWAL
else:
activity_type = _CASH_TYPE_MAP.get(type_name) # type: ignore[assignment]
if activity_type is None:
log.warning(
"ibkr: skipping cash transaction id=%s with unsupported type=%r",
getattr(cash, "transactionID", "?"),
type_name,
)
return None
dt = cash.dateTime
if isinstance(dt, datetime) and dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
elif not isinstance(dt, datetime):
dt = datetime.now(UTC) # graceful fallback — log path also fine
return Activity(
external_id=f"ibkr:cash:{cash.transactionID}",
account_id=account_id,
account_type=AccountType.GIA,
date=dt,
activity_type=activity_type,
currency=str(cash.currency),
amount=abs(amount),
)
```
- [ ] **Step 4: Run and verify all tests pass**
```bash
poetry run pytest tests/providers/test_ibkr.py -v
```
Expected: 8 tests pass.
- [ ] **Step 5: Type + lint + commit**
```bash
poetry run mypy broker_sync && poetry run ruff check broker_sync tests
git add broker_sync/providers/ibkr.py tests/providers/test_ibkr.py
git commit -m "ibkr: map Flex CashTransactions (dividends, fees, deposits)"
```
---
## Task 8: `IBKRProvider` class + account guard
**Files:**
- Modify: `broker_sync/providers/ibkr.py`
- Modify: `tests/providers/test_ibkr.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/providers/test_ibkr.py`:
```python
@pytest.mark.asyncio
async def test_ibkr_provider_fetch_returns_mapped_activities(monkeypatch) -> None: # noqa: ANN001
"""IBKRProvider.fetch() yields all mapped activities (trades + cash)."""
from broker_sync.providers.ibkr import IBKRProvider
from ibflex import client as ib_client
with open("tests/fixtures/ibkr/sample_flex.xml", "rb") as f:
xml_bytes = f.read()
monkeypatch.setattr(ib_client, "download", lambda *a, **kw: xml_bytes)
provider = IBKRProvider(
token="t",
query_id="q",
wf_account_id="wf-acct",
upstream_account_id="U12345678",
)
activities = [a async for a in provider.fetch()]
# 3 trades + 2 cash = 5
assert len(activities) == 5
types = sorted(a.activity_type.name for a in activities)
assert types == ["BUY", "BUY", "DIVIDEND", "FEE", "SELL"]
@pytest.mark.asyncio
async def test_ibkr_provider_account_mismatch_raises(monkeypatch) -> None: # noqa: ANN001
"""If Flex statement.accountId differs from the configured upstream id,
refuse to ingest. Prevents wrong-account writes from a misconfigured query."""
from broker_sync.providers.ibkr import IBKRAccountMismatchError, IBKRProvider
from ibflex import client as ib_client
with open("tests/fixtures/ibkr/sample_flex.xml", "rb") as f:
xml_bytes = f.read()
monkeypatch.setattr(ib_client, "download", lambda *a, **kw: xml_bytes)
provider = IBKRProvider(
token="t",
query_id="q",
wf_account_id="wf-acct",
upstream_account_id="U99999999", # WRONG
)
with pytest.raises(IBKRAccountMismatchError, match="U12345678"):
[a async for a in provider.fetch()]
```
- [ ] **Step 2: Run and verify the new tests fail**
```bash
poetry run pytest tests/providers/test_ibkr.py -v
```
Expected: 2 FAILs (the new tests). Existing tests still pass.
- [ ] **Step 3: Add the IBKRProvider class**
Append to `broker_sync/providers/ibkr.py`:
```python
from collections.abc import AsyncIterator # noqa: E402
class IBKRError(Exception):
"""Base class for ibkr-provider errors."""
class IBKRAccountMismatchError(IBKRError):
"""Flex statement accountId did not match configured upstream id."""
class IBKRProvider:
"""Fetches IBKR Flex Activity reports and yields broker-sync Activities.
The reconciliation step (OpenPositions vs WF-computed qty) is NOT part
of fetch() — it runs at the CLI layer after import, since it needs the
WealthfolioSink to query WF.
"""
def __init__(
self,
*,
token: str,
query_id: str,
wf_account_id: str,
upstream_account_id: str,
) -> None:
self._token = token
self._query_id = query_id
self._wf_account_id = wf_account_id
self._upstream_account_id = upstream_account_id
# Stash the parsed response for the reconciliation step.
self._last_response: FlexQueryResponse | None = None
def accounts(self) -> list[Account]:
return [
Account(
id=self._wf_account_id,
provider="ibkr",
provider_account_id=self._upstream_account_id,
account_type=AccountType.GIA,
currency="GBP", # FX-aware at trade level; account currency is GBP
)
]
async def close(self) -> None:
# No persistent HTTP client today — ibflex uses requests internally.
return
async def fetch(
self,
*,
since: datetime | None = None, # noqa: ARG002 (Flex query owns the date range)
before: datetime | None = None, # noqa: ARG002
) -> AsyncIterator[Activity]:
from ibflex import client as ib_client
from ibflex import parser as ib_parser
xml_bytes = ib_client.download(self._token, self._query_id)
response = ib_parser.parse(xml_bytes)
self._last_response = response
if not response.FlexStatements:
log.warning("ibkr: Flex response had no FlexStatements")
return
stmt = response.FlexStatements[0]
if str(stmt.accountId) != self._upstream_account_id:
raise IBKRAccountMismatchError(
f"Flex statement.accountId={stmt.accountId!r} does not match "
f"configured IBKR_ACCOUNT_ID_UPSTREAM={self._upstream_account_id!r} "
f"— refusing to ingest"
)
for trade in stmt.Trades or []:
yield _map_trade_to_activity(trade, account_id=self._wf_account_id)
for cash in stmt.CashTransactions or []:
activity = _map_cash_to_activity(cash, account_id=self._wf_account_id)
if activity is not None:
yield activity
def open_positions(self) -> list[tuple[str, Decimal]]:
"""Return ``[(canonical_symbol, position_qty), ...]`` from the most
recent fetch. Used by the reconciliation step.
Returns ``[]`` if no fetch has been called yet."""
if self._last_response is None:
return []
stmt = self._last_response.FlexStatements[0]
out: list[tuple[str, Decimal]] = []
for pos in stmt.OpenPositions or []:
symbol = canonical_symbol(
str(pos.symbol),
exchange=getattr(pos, "exchange", None),
currency=str(pos.currency),
)
out.append((symbol, Decimal(str(pos.position))))
return out
```
- [ ] **Step 4: Run and verify all tests pass**
```bash
poetry run pytest tests/providers/test_ibkr.py -v
```
Expected: 10 tests pass.
- [ ] **Step 5: Type + lint + commit**
```bash
poetry run mypy broker_sync && poetry run ruff check broker_sync tests
git add broker_sync/providers/ibkr.py tests/providers/test_ibkr.py
git commit -m "ibkr: add IBKRProvider with Flex fetch + account-mismatch guard"
```
---
## Task 9: `broker-sync ibkr` CLI command
**Files:**
- Modify: `broker_sync/cli.py`
- [ ] **Step 1: Read existing `invest_engine` command for pattern**
```bash
sed -n '140,235p' /home/wizard/code/broker-sync/broker_sync/cli.py
```
You're using this as the template — `ibkr` is structurally identical
(provider construction → pipeline → sink → reconciliation).
- [ ] **Step 2: Add the `ibkr` command**
In `broker_sync/cli.py`, after the `invest_engine` command, add:
```python
@app.command("ibkr")
def ibkr( # noqa: PLR0913
wf_base_url: str = typer.Option(..., envvar="WF_BASE_URL"),
wf_username: str = typer.Option(..., envvar="WF_USERNAME"),
wf_password: str = typer.Option(..., envvar="WF_PASSWORD"),
wf_session_path: str = typer.Option(
"/data/wealthfolio_session.json", envvar="WF_SESSION_PATH"
),
ibkr_flex_token: str = typer.Option(..., envvar="IBKR_FLEX_TOKEN"),
ibkr_flex_query_id: str = typer.Option(..., envvar="IBKR_FLEX_QUERY_ID"),
ibkr_account_id: str = typer.Option(..., envvar="IBKR_ACCOUNT_ID"),
ibkr_account_id_upstream: str = typer.Option(..., envvar="IBKR_ACCOUNT_ID_UPSTREAM"),
pushgateway_url: str = typer.Option(
"http://prometheus-prometheus-pushgateway.monitoring:9091/metrics",
envvar="PUSHGATEWAY_URL",
),
data_dir: str = typer.Option("/data", envvar="BROKER_SYNC_DATA_DIR"),
) -> None:
"""Phase 2c — daily IBKR Flex Web Service → Wealthfolio sync."""
import time
from broker_sync.dedup import SyncRecordStore
from broker_sync.metrics import push_pushgateway
from broker_sync.pipeline import sync_provider_to_wealthfolio
from broker_sync.providers.ibkr import IBKRProvider
from broker_sync.sinks.wealthfolio import WealthfolioSink
_setup_logging()
data = Path(data_dir)
data.mkdir(parents=True, exist_ok=True)
async def _run() -> None:
sink = WealthfolioSink(
base_url=wf_base_url,
username=wf_username,
password=wf_password,
session_path=wf_session_path,
)
provider = IBKRProvider(
token=ibkr_flex_token,
query_id=ibkr_flex_query_id,
wf_account_id=ibkr_account_id,
upstream_account_id=ibkr_account_id_upstream,
)
dedup = SyncRecordStore(data / "sync.db")
try:
if not Path(wf_session_path).exists():
await sink.login()
result = await sync_provider_to_wealthfolio(
provider=provider,
sink=sink,
dedup=dedup,
)
# Reconciliation: broker truth vs WF truth.
wf_qty = await sink.compute_position_qty(ibkr_account_id)
drift_metrics: list[tuple[str, dict[str, str], float]] = []
for symbol, broker_qty in provider.open_positions():
drift = broker_qty - wf_qty.get(symbol, Decimal(0))
drift_metrics.append(
(
"ibkr_position_drift_shares",
{"symbol": symbol, "account": "ibkr-uk"},
float(drift),
)
)
drift_metrics.append(
("ibkr_sync_last_success_timestamp_seconds", {}, float(time.time()))
)
await push_pushgateway("broker-sync-ibkr", drift_metrics, pushgateway_url)
finally:
await sink.close()
await provider.close()
typer.echo(
f"ibkr: fetched={result.fetched} new={result.new_after_dedup} "
f"imported={result.imported} failed={result.failed}"
)
if result.failed > 0:
sys.exit(1)
asyncio.run(_run())
```
Add the `Decimal` import at the top of `cli.py` if missing.
- [ ] **Step 3: Sanity-check the CLI compiles**
```bash
poetry run broker-sync --help | grep -i ibkr
```
Expected: `ibkr Phase 2c — daily IBKR Flex Web Service → Wealthfolio sync.`
- [ ] **Step 4: Run mypy + ruff + full pytest**
```bash
poetry run mypy broker_sync tests && poetry run ruff check . && poetry run pytest -q
```
Expected: all clean.
- [ ] **Step 5: Commit**
```bash
git add broker_sync/cli.py
git commit -m "cli: add ibkr command (Flex pull + pipeline + reconcile + metrics)"
```
---
## Task 10: Push, wait for CI, verify image
**Files:** (none — operational step)
- [ ] **Step 1: Push to GitHub + Forgejo**
```bash
git push origin main && git push forgejo main
```
- [ ] **Step 2: Wait for GHA CI to complete**
```bash
until [ "$(gh api 'repos/ViktorBarzin/broker-sync/actions/runs?per_page=1' --jq '.workflow_runs[0].status')" = "completed" ]; do sleep 15; done
gh api 'repos/ViktorBarzin/broker-sync/actions/runs?per_page=1' --jq '.workflow_runs[0] | "\(.head_sha[:8]) \(.conclusion)"'
```
Expected: `<sha> success`.
- [ ] **Step 3: Pull the new image and confirm**
```bash
docker pull viktorbarzin/broker-sync:latest
docker images viktorbarzin/broker-sync --format '{{.Tag}} {{.CreatedSince}}'
```
Expected: `latest` was created within the last few minutes.
---
## Task 11: Vault secrets + WF account creation
**Files:** (operational — no code changes)
- [ ] **Step 1: User completes the IBKR Client Portal steps**
Follow the design's setup checklist Step 1:
- Enable Flex Web Service → copy Token.
- Create Activity Flex Query → copy Query ID.
- Note the account number (e.g., `U12345678`).
- [ ] **Step 2: Create the Wealthfolio account**
```bash
WF_BASE="https://wealthfolio.viktorbarzin.me" # adjust if internal-only
WF_PASS=$(vault kv get -field=wf_password secret/broker-sync)
curl -sS -c /tmp/wf-jar -X POST "$WF_BASE/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"password\":\"$WF_PASS\"}" -o /dev/null
WF_UUID=$(curl -sS -b /tmp/wf-jar -X POST "$WF_BASE/api/v1/accounts" \
-H 'Content-Type: application/json' \
-d '{"name":"Interactive Brokers (UK)","accountType":"GIA","currency":"GBP","isActive":true}' \
| jq -r '.id')
echo "WF account UUID = $WF_UUID"
```
Expected: prints a UUID. Note it down for the next step.
- [ ] **Step 3: Put the 4 IBKR secrets into Vault**
```bash
vault kv patch secret/broker-sync \
ibkr_flex_token='<paste-from-IBKR-portal>' \
ibkr_flex_query_id='<paste-from-IBKR-portal>' \
ibkr_account_id='<WF_UUID-from-step-2>' \
ibkr_account_id_upstream='<your-IBKR-account-number-like-U12345678>'
```
- [ ] **Step 4: Verify the secrets are readable**
```bash
vault kv get -format=json secret/broker-sync | jq '.data.data | {token: (.ibkr_flex_token[0:6]+"..."), query_id, account_id, account_id_upstream}'
```
Expected: all four fields present, token truncated.
---
## Task 12: Terraform CronJob + alerts
**Files:**
- Modify: `infra/stacks/broker-sync/main.tf`
- [ ] **Step 1: Open `infra/stacks/broker-sync/main.tf` and find the `trading212` CronJob**
```bash
grep -n 'kubernetes_cron_job_v1.*trading212\|broker-sync-trading212' /home/wizard/code/infra/stacks/broker-sync/main.tf
```
Use it as the template — copy/paste then adjust the diffs.
- [ ] **Step 2: Add the IBKR CronJob resource**
After the `trading212` CronJob block, add:
```hcl
# IBKR Flex Web Service daily sync. Phase 2c deliverable.
resource "kubernetes_cron_job_v1" "ibkr" {
metadata {
name = "broker-sync-ibkr"
namespace = kubernetes_namespace.broker_sync.metadata[0].name
labels = { app = "broker-sync", component = "ibkr" }
}
spec {
schedule = "0 2 * * *" # 02:00 UK
concurrency_policy = "Forbid"
starting_deadline_seconds = 300
successful_jobs_history_limit = 3
failed_jobs_history_limit = 5
job_template {
metadata {}
spec {
backoff_limit = 2
ttl_seconds_after_finished = 86400
template {
metadata {
labels = { app = "broker-sync", component = "ibkr" }
}
spec {
restart_policy = "OnFailure"
security_context {
fs_group = 10001
}
container {
name = "broker-sync"
image = local.broker_sync_image
command = ["broker-sync", "ibkr"]
env {
name = "BROKER_SYNC_DATA_DIR"
value = "/data"
}
env {
name = "WF_SESSION_PATH"
value = "/data/wealthfolio_session.json"
}
env {
name = "WF_BASE_URL"
value_from { secret_key_ref { name = "broker-sync-secrets"; key = "wf_base_url" } }
}
env {
name = "WF_USERNAME"
value_from { secret_key_ref { name = "broker-sync-secrets"; key = "wf_username" } }
}
env {
name = "WF_PASSWORD"
value_from { secret_key_ref { name = "broker-sync-secrets"; key = "wf_password" } }
}
env {
name = "IBKR_FLEX_TOKEN"
value_from { secret_key_ref { name = "broker-sync-secrets"; key = "ibkr_flex_token" } }
}
env {
name = "IBKR_FLEX_QUERY_ID"
value_from { secret_key_ref { name = "broker-sync-secrets"; key = "ibkr_flex_query_id" } }
}
env {
name = "IBKR_ACCOUNT_ID"
value_from { secret_key_ref { name = "broker-sync-secrets"; key = "ibkr_account_id" } }
}
env {
name = "IBKR_ACCOUNT_ID_UPSTREAM"
value_from { secret_key_ref { name = "broker-sync-secrets"; key = "ibkr_account_id_upstream" } }
}
volume_mount {
name = "data"
mount_path = "/data"
}
resources {
requests = { cpu = "20m", memory = "128Mi" }
limits = { memory = "256Mi" }
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.data_encrypted.metadata[0].name
}
}
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
```
- [ ] **Step 3: Format the terraform**
```bash
cd /home/wizard/code/infra/stacks/broker-sync && terraform fmt main.tf
```
- [ ] **Step 4: Plan**
```bash
/home/wizard/code/infra/scripts/tg plan 2>&1 | tail -20
```
Expected: `Plan: 1 to add, 0 to change, 0 to destroy.` (the new ibkr CronJob).
- [ ] **Step 5: Apply**
```bash
/home/wizard/code/infra/scripts/tg apply --non-interactive 2>&1 | tail -5
```
Expected: `Apply complete! Resources: 1 added, ...`.
- [ ] **Step 6: Verify the CronJob exists**
```bash
kubectl -n broker-sync get cronjob broker-sync-ibkr
```
Expected: row appears with `SCHEDULE = 0 2 * * *`.
- [ ] **Step 7: Commit**
```bash
cd /home/wizard/code/infra
git add stacks/broker-sync/main.tf
git commit -m "broker-sync: add IBKR Flex daily CronJob"
git push origin master
```
---
## Task 13: Manual smoke run + verification
**Files:** (none — operational)
- [ ] **Step 1: Trigger the CronJob manually**
```bash
kubectl -n broker-sync create job --from=cronjob/broker-sync-ibkr broker-sync-ibkr-smoke-$(date +%s)
```
- [ ] **Step 2: Wait for completion + check status**
```bash
JOB=$(kubectl -n broker-sync get jobs --sort-by=.metadata.creationTimestamp -o name | grep broker-sync-ibkr-smoke | tail -1)
until [ "$(kubectl -n broker-sync get $JOB -o jsonpath='{.status.succeeded}{.status.failed}' 2>/dev/null)" != "" ]; do sleep 5; done
kubectl -n broker-sync get $JOB
```
Expected: `STATUS = Complete`. (If `Failed`, check logs in step 3 and debug.)
- [ ] **Step 3: Inspect the logs**
```bash
kubectl -n broker-sync logs -l job-name=$(basename $JOB) --tail=200
```
Look for:
- `ibkr: fetched=0 new=0 imported=0 failed=0` (account is empty, so zero
rows is correct).
- A `pushgateway: pushed N metrics` line.
- No tracebacks.
- [ ] **Step 4: Verify the WF account exists with no activities**
```bash
WF_PASS=$(vault kv get -field=wf_password secret/broker-sync)
curl -sS -c /tmp/wf-jar -X POST https://wealthfolio.viktorbarzin.me/api/v1/auth/login \
-H 'Content-Type: application/json' -d "{\"password\":\"$WF_PASS\"}" -o /dev/null
curl -sS -b /tmp/wf-jar https://wealthfolio.viktorbarzin.me/api/v1/accounts | jq '.[] | select(.name=="Interactive Brokers (UK)")'
```
Expected: prints the account JSON with the UUID from Task 11 Step 2.
- [ ] **Step 5: Verify Pushgateway received the metrics**
```bash
kubectl -n monitoring port-forward svc/prometheus-prometheus-pushgateway 9091:9091 &
sleep 2
curl -sS http://localhost:9091/metrics | grep -E 'ibkr_(position_drift_shares|sync_last_success)'
kill %1
```
Expected: `ibkr_sync_last_success_timestamp_seconds` shows a recent
unix timestamp. `ibkr_position_drift_shares` may be absent if there
were no open positions today, which is correct for an empty account.
---
## Task 14: Provider docs (for future-you)
**Files:**
- Create: `docs/providers/ibkr.md`
- [ ] **Step 1: Write the production-facing provider doc**
Create `docs/providers/ibkr.md`:
```markdown
# Provider: Interactive Brokers (IBKR Flex Web Service)
Pulls a daily Activity Flex Query via the `ibflex` library, maps Trades +
CashTransactions to broker-sync Activities, and reconciles broker-side
OpenPositions against WF-computed quantities.
## When this runs
- K8s CronJob `broker-sync-ibkr` in the `broker-sync` namespace, daily 02:00 UK.
- Manual: `kubectl -n broker-sync create job --from=cronjob/broker-sync-ibkr broker-sync-ibkr-manual-1`.
## Secrets (Vault `secret/broker-sync`)
| Key | Description |
|---|---|
| `ibkr_flex_token` | Flex Web Service token (1-year validity, rotate via IBKR Client Portal) |
| `ibkr_flex_query_id` | Activity Flex Query ID (5-7 digit number) |
| `ibkr_account_id` | Wealthfolio account UUID for "Interactive Brokers (UK)" |
| `ibkr_account_id_upstream` | IBKR-side account number (e.g., `U12345678`) — guards against wrong-account ingestion |
## Flex Query design
| Section | Fields used |
|---|---|
| Account Information | accountId |
| Trades | tradeID, tradeDate, tradeTime, symbol, buySell, quantity, tradePrice, currency, ibCommission, assetCategory |
| Cash Transactions | transactionID, dateTime, type, amount, currency, description |
| Open Positions | symbol, position, markPrice, currency, assetCategory |
| Securities Information | symbol, description, conid |
Date range: `Last 90 Days` — trailing window so a missed cron run
doesn't lose data. SyncRecordStore makes overlapping pulls idempotent.
Switch to `Year to Date` or `Custom Date Range` only for one-time
historical backfills.
## Cash type mapping
| IBKR Flex type | broker-sync ActivityType |
|---|---|
| Dividends | DIVIDEND |
| Withholding Tax | FEE |
| Broker Interest Received | DIVIDEND |
| Broker Interest Paid | FEE |
| Commission Adjustments | FEE |
| Other Fees | FEE |
| Deposits & Withdrawals | DEPOSIT (amount > 0) or WITHDRAWAL (amount < 0) |
| anything else | skipped + WARNING logged (refusal-to-guess) |
## External IDs (dedup keys)
- Trades: `ibkr:trade:<tradeID>`
- Cash: `ibkr:cash:<transactionID>`
Both are stable across re-runs the `dedup.SyncRecordStore` rejects
already-seen IDs.
## Symbol canonicalisation
LSE-listed GBP instruments get a `.L` suffix (Wealthfolio convention).
US instruments and anything already suffixed pass through unchanged.
## Position reconciliation
Each run pushes to Pushgateway:
- `ibkr_position_drift_shares{symbol, account}` broker_qty wf_qty per asset
- `ibkr_sync_last_success_timestamp_seconds` unix timestamp
Alerts (defined in monitoring stack TBD until first non-zero drift):
- `IBKRPositionDrift{symbol}` `|drift| > 0.01` for >24h, Slack `#security`.
- `IBKRSyncStale` — timestamp > 36h old.
- `IBKRFlexTokenExpired` — Loki rule on the "code 1003" log line.
## Token rotation
Flex tokens expire after 1 year. When the cron starts failing with
`ResponseCodeError(code=1003)`:
1. Sign in to IBKR Client Portal → Reports → Settings → Flex Web Service → regenerate token.
2. `vault kv patch secret/broker-sync ibkr_flex_token='<new-token>'`.
3. ExternalSecrets controller picks it up within 15 min; no manual restart needed.
## Spec / plan
Design: `docs/specs/2026-05-26-ibkr-ingest-design.md`
Implementation plan: `docs/plans/2026-05-26-ibkr-flex-ingestion.md`
```
- [ ] **Step 2: Commit**
```bash
cd /home/wizard/code/broker-sync
git add docs/providers/ibkr.md
git commit -m "docs: add IBKR provider runbook"
git push origin main && git push forgejo main
```
---
## Task 15: Acceptance — 7-day soak
**Files:** (none — observational)
- [ ] **Step 1: Set a 7-day calendar reminder to re-check**
Set a reminder for `2026-06-02` (today + 7 days).
- [ ] **Step 2: On 2026-06-02, run the acceptance check**
```bash
# Last 7 days of CronJob outcomes
kubectl -n broker-sync get jobs --sort-by=.metadata.creationTimestamp -o wide \
| grep broker-sync-ibkr-2
# Pushgateway should have a recent success timestamp
kubectl -n monitoring port-forward svc/prometheus-prometheus-pushgateway 9091:9091 &
sleep 2
curl -sS http://localhost:9091/metrics | grep ibkr_sync_last_success
kill %1
# Pushgateway drift should be zero on all symbols (account still empty, or
# else broker matches WF)
curl -sS http://localhost:9091/metrics | grep ibkr_position_drift_shares
```
Expected:
- ≥6 of the 7 nightly runs `Complete`.
- `ibkr_sync_last_success_timestamp_seconds` within the last 36 hours.
- `ibkr_position_drift_shares` all zero.
- [ ] **Step 3: If all green, close the implementation plan**
Mark this plan file as `Status: Done` at the top and commit.
If not green, file beads tasks for the specific issues and revisit.
---
## Self-review notes
- **Spec coverage**: every section of `docs/specs/2026-05-26-ibkr-ingest-design.md`
maps to one or more tasks (deps→1, fixtures→2, metrics→3, sink helper→4,
symbol canon→5, trade map→6, cash map→7, provider→8, CLI→9, image→10,
setup→11, CronJob→12, smoke→13, docs→14, soak→15).
- **Placeholder scan**: no `TBD` in the plan body. The doc file
`docs/providers/ibkr.md` includes one explicit TBD about
PrometheusRule definitions — that's intentional, deferred to the
monitoring stack work (out-of-scope here; first non-zero drift event
will prompt the alert PR).
- **Type consistency**: `IBKRProvider.fetch` is `AsyncIterator[Activity]`
throughout. `compute_position_qty` returns `dict[str, Decimal]` in
both the sink and the CLI consumer. External_id schemes
(`ibkr:trade:<id>` and `ibkr:cash:<id>`) match between the mapper, the
provider, and the documentation.