diff --git a/broker_sync/cli.py b/broker_sync/cli.py index 7f855f5..64057f7 100644 --- a/broker_sync/cli.py +++ b/broker_sync/cli.py @@ -310,6 +310,16 @@ def ibkr( float(drift), ) ) + # Cash balances (one row per currency from CashReport, plus a + # BASE_SUMMARY row consolidated in account base currency). + for currency, ending_cash in provider.cash_balances(): + drift_metrics.append( + ( + "ibkr_cash_balance", + {"currency": currency, "account": "ibkr-uk"}, + float(ending_cash), + ) + ) drift_metrics.append( ("ibkr_sync_last_success_timestamp_seconds", {}, float(time.time())) ) diff --git a/broker_sync/providers/ibkr.py b/broker_sync/providers/ibkr.py index fcff89f..e180bcb 100644 --- a/broker_sync/providers/ibkr.py +++ b/broker_sync/providers/ibkr.py @@ -257,3 +257,20 @@ class IBKRProvider: ) out.append((symbol, Decimal(str(pos.position)))) return out + + def cash_balances(self) -> list[tuple[str, Decimal]]: + """Return ``[(currency, ending_cash), ...]`` from the CashReport. + + Includes the ``BASE_SUMMARY`` aggregate row (account base currency + consolidated) plus any per-currency rows. Empty list if no + CashReport section in the Flex query or before first ``fetch()``. + """ + if self._last_response is None: + return [] + stmt = self._last_response.FlexStatements[0] + out: list[tuple[str, Decimal]] = [] + for row in stmt.CashReport or []: + if row.endingCash is None or row.currency is None: + continue + out.append((str(row.currency), Decimal(str(row.endingCash)))) + return out diff --git a/tests/fixtures/ibkr/sample_flex.xml b/tests/fixtures/ibkr/sample_flex.xml index 0d82fcf..d3130a3 100644 --- a/tests/fixtures/ibkr/sample_flex.xml +++ b/tests/fixtures/ibkr/sample_flex.xml @@ -16,6 +16,10 @@ + + + + diff --git a/tests/providers/test_ibkr.py b/tests/providers/test_ibkr.py index 8dfba07..edbc51d 100644 --- a/tests/providers/test_ibkr.py +++ b/tests/providers/test_ibkr.py @@ -194,3 +194,31 @@ async def test_ibkr_provider_open_positions_after_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")} + + +async def test_ibkr_provider_cash_balances_after_fetch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cash_balances() returns (currency, ending_cash) tuples from CashReport.""" + 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", + ) + [a async for a in provider.fetch()] + + balances = provider.cash_balances() + # Fixture has BASE_SUMMARY + USD rows, both 1.23 + assert dict(balances) == {"BASE_SUMMARY": Decimal("1.23"), "USD": Decimal("1.23")} + + +def test_ibkr_provider_cash_balances_before_fetch_returns_empty() -> None: + """No CashReport data before fetch().""" + provider = IBKRProvider(token="t", query_id="q", upstream_account_id="U12345678") + assert provider.cash_balances() == []