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", 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", 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", 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")}