From ceb652b62363cce2ea71147a0af07e035d72e5e4 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 27 May 2026 09:18:42 +0000 Subject: [PATCH] ibkr: use IBKR account number as the canonical Account.id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: provider passed the WF UUID as Account.id. ensure_account looks up existing accounts by (provider, providerAccountId=Account.id), so the WF-UUID-as-providerAccountId would never match the manually-created account (which has providerAccountId=U13279690), causing the pipeline to create a duplicate WF account on every cron run. Fix: Account.id is now the IBKR account number (U13279690) throughout. The pipeline's _ensure_accounts() resolves it to the WF UUID via the canonical (provider, providerAccountId) lookup; activities are remapped before import. CLI no longer takes the WF UUID — derives it post-import via a cheap idempotent ensure_account call for the reconciliation step. Co-Authored-By: Claude Opus 4.7 --- broker_sync/cli.py | 13 ++++++++++--- broker_sync/providers/ibkr.py | 12 +++++++----- tests/providers/test_ibkr.py | 3 --- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/broker_sync/cli.py b/broker_sync/cli.py index cef7526..7f855f5 100644 --- a/broker_sync/cli.py +++ b/broker_sync/cli.py @@ -240,7 +240,6 @@ def ibkr( ), 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", @@ -254,6 +253,10 @@ def ibkr( broker-sync Activities, pushes through the shared pipeline, then reconciles broker-reported OpenPositions against WF-computed quantities and publishes a Pushgateway drift metric. + + The Wealthfolio account UUID is resolved via the pipeline's + ensure_account(provider="ibkr", providerAccountId=IBKR_ACCOUNT_ID_UPSTREAM) + lookup — no need to wire the UUID in as a separate env var. """ import time from decimal import Decimal @@ -278,7 +281,6 @@ def ibkr( 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") @@ -291,8 +293,13 @@ def ibkr( dedup=dedup, ) + # Resolve WF UUID for reconciliation. ensure_account is idempotent + # and already ran inside sync_provider_to_wealthfolio; this is a + # cheap re-lookup that returns the same UUID. + wf_uuid = await sink.ensure_account(provider.accounts()[0]) + # Reconciliation: broker truth vs WF truth. - wf_qty = await sink.compute_position_qty(ibkr_account_id) + wf_qty = await sink.compute_position_qty(wf_uuid) 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)) diff --git a/broker_sync/providers/ibkr.py b/broker_sync/providers/ibkr.py index 741c79a..fcff89f 100644 --- a/broker_sync/providers/ibkr.py +++ b/broker_sync/providers/ibkr.py @@ -179,12 +179,14 @@ class IBKRProvider: *, 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 + # Single source of truth — the IBKR account number (e.g. U13279690). + # The pipeline's _ensure_accounts() resolves this to a Wealthfolio + # UUID via (provider="ibkr", providerAccountId=upstream_account_id); + # activities are remapped to the WF UUID before import. self._upstream_account_id = upstream_account_id # Stashed for the reconciliation step after fetch() drains. self._last_response: Any = None @@ -192,7 +194,7 @@ class IBKRProvider: def accounts(self) -> list[Account]: return [ Account( - id=self._wf_account_id, + id=self._upstream_account_id, name="Interactive Brokers (UK)", account_type=AccountType.GIA, currency="GBP", # FX-aware per-trade; account ccy is GBP @@ -232,10 +234,10 @@ class IBKRProvider: ) for trade in stmt.Trades or []: - yield _map_trade_to_activity(trade, account_id=self._wf_account_id) + yield _map_trade_to_activity(trade, account_id=self._upstream_account_id) for cash in stmt.CashTransactions or []: - activity = _map_cash_to_activity(cash, account_id=self._wf_account_id) + activity = _map_cash_to_activity(cash, account_id=self._upstream_account_id) if activity is not None: yield activity diff --git a/tests/providers/test_ibkr.py b/tests/providers/test_ibkr.py index ea83e26..8dfba07 100644 --- a/tests/providers/test_ibkr.py +++ b/tests/providers/test_ibkr.py @@ -145,7 +145,6 @@ async def test_ibkr_provider_fetch_returns_mapped_activities( provider = IBKRProvider( token="t", query_id="q", - wf_account_id="wf-acct", upstream_account_id="U12345678", ) activities = [a async for a in provider.fetch()] @@ -168,7 +167,6 @@ async def test_ibkr_provider_account_mismatch_raises( provider = IBKRProvider( token="t", query_id="q", - wf_account_id="wf-acct", upstream_account_id="U99999999", # WRONG ) with pytest.raises(IBKRAccountMismatchError, match="U12345678"): @@ -188,7 +186,6 @@ async def test_ibkr_provider_open_positions_after_fetch( provider = IBKRProvider( token="t", query_id="q", - wf_account_id="wf-acct", upstream_account_id="U12345678", ) # drain the iterator before reading positions