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>
This commit is contained in:
parent
882415464e
commit
e83c5a0a8f
2 changed files with 454 additions and 0 deletions
199
tests/providers/test_ibkr.py
Normal file
199
tests/providers/test_ibkr.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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")}
|
||||
Loading…
Add table
Add a link
Reference in a new issue