ibkr: emit ibkr_cash_balance{currency, account} per CashReport row
Each daily run now pushes one Pushgateway metric per currency row from the Flex Activity Query's CashReport section (typically BASE_SUMMARY aggregate + one row per held currency). Makes dormant-account balance checks trivial and adds a Grafana surface for cash drift alerting. Requires the Activity Flex Query in IBKR Client Portal to have the CashReport section enabled. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
17c2a69c6c
commit
3427f5c9e1
4 changed files with 59 additions and 0 deletions
|
|
@ -310,6 +310,16 @@ def ibkr(
|
||||||
float(drift),
|
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(
|
drift_metrics.append(
|
||||||
("ibkr_sync_last_success_timestamp_seconds", {}, float(time.time()))
|
("ibkr_sync_last_success_timestamp_seconds", {}, float(time.time()))
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -257,3 +257,20 @@ class IBKRProvider:
|
||||||
)
|
)
|
||||||
out.append((symbol, Decimal(str(pos.position))))
|
out.append((symbol, Decimal(str(pos.position))))
|
||||||
return out
|
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
|
||||||
|
|
|
||||||
4
tests/fixtures/ibkr/sample_flex.xml
vendored
4
tests/fixtures/ibkr/sample_flex.xml
vendored
|
|
@ -16,6 +16,10 @@
|
||||||
<OpenPosition symbol="VUAG" position="8" markPrice="108.20" currency="GBP" assetCategory="STK"/>
|
<OpenPosition symbol="VUAG" position="8" markPrice="108.20" currency="GBP" assetCategory="STK"/>
|
||||||
<OpenPosition symbol="AAPL" position="5" markPrice="181.00" currency="USD" assetCategory="STK"/>
|
<OpenPosition symbol="AAPL" position="5" markPrice="181.00" currency="USD" assetCategory="STK"/>
|
||||||
</OpenPositions>
|
</OpenPositions>
|
||||||
|
<CashReport>
|
||||||
|
<CashReportCurrency accountId="U12345678" currency="BASE_SUMMARY" levelOfDetail="BaseCurrency" startingCash="1.23" endingCash="1.23" endingSettledCash="1.23"/>
|
||||||
|
<CashReportCurrency accountId="U12345678" currency="USD" levelOfDetail="Currency" startingCash="1.23" endingCash="1.23" endingSettledCash="1.23"/>
|
||||||
|
</CashReport>
|
||||||
</FlexStatement>
|
</FlexStatement>
|
||||||
</FlexStatements>
|
</FlexStatements>
|
||||||
</FlexQueryResponse>
|
</FlexQueryResponse>
|
||||||
|
|
|
||||||
|
|
@ -194,3 +194,31 @@ async def test_ibkr_provider_open_positions_after_fetch(
|
||||||
positions = provider.open_positions()
|
positions = provider.open_positions()
|
||||||
# VUAG → VUAG.L (LSE inferred from GBP); AAPL unchanged (USD)
|
# VUAG → VUAG.L (LSE inferred from GBP); AAPL unchanged (USD)
|
||||||
assert dict(positions) == {"VUAG.L": Decimal("8"), "AAPL": Decimal("5")}
|
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() == []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue