ibkr: use IBKR account number as the canonical Account.id
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

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 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-27 09:18:42 +00:00
parent 30af5fe2c9
commit ceb652b623
3 changed files with 17 additions and 11 deletions

View file

@ -240,7 +240,6 @@ def ibkr(
), ),
ibkr_flex_token: str = typer.Option(..., envvar="IBKR_FLEX_TOKEN"), ibkr_flex_token: str = typer.Option(..., envvar="IBKR_FLEX_TOKEN"),
ibkr_flex_query_id: str = typer.Option(..., envvar="IBKR_FLEX_QUERY_ID"), 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"), ibkr_account_id_upstream: str = typer.Option(..., envvar="IBKR_ACCOUNT_ID_UPSTREAM"),
pushgateway_url: str = typer.Option( pushgateway_url: str = typer.Option(
"http://prometheus-prometheus-pushgateway.monitoring:9091/metrics", "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics",
@ -254,6 +253,10 @@ def ibkr(
broker-sync Activities, pushes through the shared pipeline, then broker-sync Activities, pushes through the shared pipeline, then
reconciles broker-reported OpenPositions against WF-computed quantities reconciles broker-reported OpenPositions against WF-computed quantities
and publishes a Pushgateway drift metric. 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 import time
from decimal import Decimal from decimal import Decimal
@ -278,7 +281,6 @@ def ibkr(
provider = IBKRProvider( provider = IBKRProvider(
token=ibkr_flex_token, token=ibkr_flex_token,
query_id=ibkr_flex_query_id, query_id=ibkr_flex_query_id,
wf_account_id=ibkr_account_id,
upstream_account_id=ibkr_account_id_upstream, upstream_account_id=ibkr_account_id_upstream,
) )
dedup = SyncRecordStore(data / "sync.db") dedup = SyncRecordStore(data / "sync.db")
@ -291,8 +293,13 @@ def ibkr(
dedup=dedup, 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. # 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]] = [] drift_metrics: list[tuple[str, dict[str, str], float]] = []
for symbol, broker_qty in provider.open_positions(): for symbol, broker_qty in provider.open_positions():
drift = broker_qty - wf_qty.get(symbol, Decimal(0)) drift = broker_qty - wf_qty.get(symbol, Decimal(0))

View file

@ -179,12 +179,14 @@ class IBKRProvider:
*, *,
token: str, token: str,
query_id: str, query_id: str,
wf_account_id: str,
upstream_account_id: str, upstream_account_id: str,
) -> None: ) -> None:
self._token = token self._token = token
self._query_id = query_id 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 self._upstream_account_id = upstream_account_id
# Stashed for the reconciliation step after fetch() drains. # Stashed for the reconciliation step after fetch() drains.
self._last_response: Any = None self._last_response: Any = None
@ -192,7 +194,7 @@ class IBKRProvider:
def accounts(self) -> list[Account]: def accounts(self) -> list[Account]:
return [ return [
Account( Account(
id=self._wf_account_id, id=self._upstream_account_id,
name="Interactive Brokers (UK)", name="Interactive Brokers (UK)",
account_type=AccountType.GIA, account_type=AccountType.GIA,
currency="GBP", # FX-aware per-trade; account ccy is GBP currency="GBP", # FX-aware per-trade; account ccy is GBP
@ -232,10 +234,10 @@ class IBKRProvider:
) )
for trade in stmt.Trades or []: 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 []: 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: if activity is not None:
yield activity yield activity

View file

@ -145,7 +145,6 @@ async def test_ibkr_provider_fetch_returns_mapped_activities(
provider = IBKRProvider( provider = IBKRProvider(
token="t", token="t",
query_id="q", query_id="q",
wf_account_id="wf-acct",
upstream_account_id="U12345678", upstream_account_id="U12345678",
) )
activities = [a async for a in provider.fetch()] activities = [a async for a in provider.fetch()]
@ -168,7 +167,6 @@ async def test_ibkr_provider_account_mismatch_raises(
provider = IBKRProvider( provider = IBKRProvider(
token="t", token="t",
query_id="q", query_id="q",
wf_account_id="wf-acct",
upstream_account_id="U99999999", # WRONG upstream_account_id="U99999999", # WRONG
) )
with pytest.raises(IBKRAccountMismatchError, match="U12345678"): with pytest.raises(IBKRAccountMismatchError, match="U12345678"):
@ -188,7 +186,6 @@ async def test_ibkr_provider_open_positions_after_fetch(
provider = IBKRProvider( provider = IBKRProvider(
token="t", token="t",
query_id="q", query_id="q",
wf_account_id="wf-acct",
upstream_account_id="U12345678", upstream_account_id="U12345678",
) )
# drain the iterator before reading positions # drain the iterator before reading positions