broker-sync/tests/providers/test_ibkr.py
Viktor Barzin e83c5a0a8f ibkr: add Flex provider — Trade/Cash mapping + OpenPositions snapshot
Maps Trades (BUY/SELL) and CashTransactions (DIVIDEND, TAX, INTEREST, FEE,
DEPOSIT, WITHDRAWAL) from an IBKR Flex Activity Query to broker-sync
Activity objects. Adds canonical_symbol helper (LSE → .L suffix when
exchange=LSE* or currency=GBP). Exposes OpenPositions for the
reconciliation step that runs at the CLI layer.

Guards against wrong-account writes by checking
stmt.accountId == IBKR_ACCOUNT_ID_UPSTREAM before yielding any activities.

13 unit tests cover all the mappings + the mismatch guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:28:35 +00:00

199 lines
6.4 KiB
Python

from __future__ import annotations
from datetime import datetime
from decimal import Decimal
import pytest
from broker_sync.models import ActivityType
from broker_sync.providers.ibkr import (
IBKRAccountMismatchError,
IBKRProvider,
_map_cash_to_activity,
_map_trade_to_activity,
canonical_symbol,
)
# -- canonical_symbol --
def test_canonical_symbol_lse_etf_gets_l_suffix() -> None:
assert canonical_symbol("VUAG", exchange="LSEETF", 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="LSEETF", currency="GBP") == "VUAG.L"
# -- Trade mapping --
def test_map_trade_buy_to_activity() -> None:
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, comm -1.05
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
def test_map_trade_sell_to_activity() -> None:
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
trade = r.FlexStatements[0].Trades[2] # T1003: 2 VUAG SELL @ 108.00 GBP
activity = _map_trade_to_activity(trade, account_id="wf-acct")
assert activity.activity_type == ActivityType.SELL
assert activity.symbol == "VUAG.L"
assert activity.quantity == Decimal("2")
assert activity.unit_price == Decimal("108.00")
def test_map_trade_us_stock_keeps_usd_currency_and_no_suffix() -> None:
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
trade = r.FlexStatements[0].Trades[1] # T1002: AAPL BUY USD
activity = _map_trade_to_activity(trade, account_id="wf-acct")
assert activity.symbol == "AAPL"
assert activity.currency == "USD"
# -- Cash mapping --
def test_map_cash_dividend_to_activity() -> None:
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")
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_tax_activity() -> None:
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")
assert activity is not None
assert activity.activity_type == ActivityType.TAX
assert activity.amount == Decimal("0.35") # always positive on Activity
def test_map_cash_unknown_type_returns_none_and_logs(caplog: pytest.LogCaptureFixture) -> None:
"""Unknown CashTransaction.type produces None + a WARNING log line."""
class FakeType:
name = "FrobnicatedThing"
class FakeCash:
transactionID = "C9999"
dateTime = None
type = FakeType()
amount = Decimal("0")
currency = "GBP"
with caplog.at_level("WARNING"):
result = _map_cash_to_activity(FakeCash, account_id="wf-acct")
assert result is None
assert any("FROBNICATEDTHING" in r.message for r in caplog.records)
# -- IBKRProvider end-to-end --
async def test_ibkr_provider_fetch_returns_mapped_activities(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""IBKRProvider.fetch() yields all mapped activities (trades + cash)."""
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", "SELL", "TAX"]
async def test_ibkr_provider_account_mismatch_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Mismatched accountId raises and writes nothing."""
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()]
async def test_ibkr_provider_open_positions_after_fetch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""open_positions() returns canonicalised symbol + qty after fetch drained."""
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",
)
# drain the iterator before reading positions
[a async for a in provider.fetch()]
positions = provider.open_positions()
# VUAG → VUAG.L (LSE inferred from GBP); AAPL unchanged (USD)
assert dict(positions) == {"VUAG.L": Decimal("8"), "AAPL": Decimal("5")}