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>
196 lines
6.3 KiB
Python
196 lines
6.3 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",
|
|
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")}
|