Skip to content

Commit 18e3a53

Browse files
committed
test(ibkr): cover account snapshot readiness
1 parent 9ca9088 commit 18e3a53

4 files changed

Lines changed: 177 additions & 9 deletions

File tree

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v2.3.0
3+
rev: v6.0.0
44
hooks:
55
- id: check-yaml
66
- id: end-of-file-fixer
77
- id: trailing-whitespace
88
- repo: https://github.com/astral-sh/ruff-pre-commit
99
# Ruff version.
10-
rev: v0.12.5
10+
rev: v0.14.4
1111
hooks:
1212
# Run the linter.
1313
- id: ruff-check
@@ -19,13 +19,13 @@ repos:
1919
- id: ruff-format
2020
- repo: https://github.com/astral-sh/uv-pre-commit
2121
# uv version.
22-
rev: 0.8.3
22+
rev: 0.9.8
2323
hooks:
2424
- id: uv-sync
2525
args: ["--locked", "--all-packages"]
2626
- repo: https://github.com/astral-sh/uv-pre-commit
2727
# uv version.
28-
rev: 0.8.3
28+
rev: 0.9.8
2929
hooks:
3030
# Update the uv lockfile
3131
- id: uv-lock

tests/test_ibkr.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import asyncio
2+
from types import SimpleNamespace
23

34
import pytest
4-
from ib_async import IB, Contract, Order, OrderStatus, Stock, Ticker, Trade
5+
from ib_async import (
6+
IB,
7+
AccountValue,
8+
Contract,
9+
Order,
10+
OrderStatus,
11+
Stock,
12+
Ticker,
13+
Trade,
14+
)
515

616
from thetagang import log
717
from thetagang.ibkr import (
@@ -25,6 +35,8 @@ def mock_ib(mocker):
2535
mock.orderStatusEvent.__iadd__ = mocker.Mock(
2636
return_value=None
2737
) # Allow += operation
38+
mock.wrapper = mocker.Mock()
39+
mock.wrapper.accountValues = {}
2840
return mock
2941

3042

@@ -268,6 +280,7 @@ async def test_refresh_account_updates_uses_timeout_wrapper(ibkr, mocker):
268280
req_future: asyncio.Future = asyncio.get_running_loop().create_future()
269281
req_future.set_result(None)
270282
ibkr.ib.reqAccountUpdatesAsync = mocker.Mock(return_value=req_future)
283+
mocker.patch.object(ibkr, "_account_snapshot_ready", side_effect=[False, True])
271284
await_wrapper = mocker.patch.object(
272285
ibkr, "_await_with_timeout", new=mocker.AsyncMock(return_value=None)
273286
)
@@ -317,6 +330,106 @@ async def test_refresh_account_updates_propagates_timeout(ibkr, mocker):
317330
await ibkr.refresh_account_updates("ACC123")
318331

319332

333+
async def test_refresh_account_updates_skips_when_snapshot_ready(ibkr, mocker):
334+
"""No request issued when account snapshot already populated."""
335+
mocker.patch.object(ibkr, "_account_snapshot_ready", return_value=True)
336+
337+
await ibkr.refresh_account_updates("ACC123")
338+
339+
ibkr.ib.reqAccountUpdatesAsync.assert_not_called()
340+
341+
342+
async def test_refresh_account_updates_allows_timeout_if_data_ready(ibkr, mocker):
343+
"""A timeout is ignored when snapshot becomes ready while waiting."""
344+
mocker.patch.object(ibkr, "_account_snapshot_ready", side_effect=[False, True])
345+
ibkr.ib.reqAccountUpdatesAsync = mocker.Mock(return_value=object())
346+
mocker.patch.object(
347+
ibkr,
348+
"_await_with_timeout",
349+
new=mocker.AsyncMock(
350+
side_effect=IBKRRequestTimeout(
351+
"account updates", ibkr.api_response_wait_time
352+
)
353+
),
354+
)
355+
356+
await ibkr.refresh_account_updates("ACC123")
357+
358+
assert ibkr._account_snapshot_ready.call_count == 2
359+
360+
361+
async def test_refresh_account_updates_raises_when_snapshot_never_populates(
362+
ibkr, mocker
363+
):
364+
"""If data never arrives, an IBKRRequestTimeout is raised."""
365+
mocker.patch.object(ibkr, "_account_snapshot_ready", return_value=False)
366+
ibkr.ib.reqAccountUpdatesAsync = mocker.Mock(return_value=object())
367+
mocker.patch.object(
368+
ibkr, "_await_with_timeout", new=mocker.AsyncMock(return_value=None)
369+
)
370+
371+
with pytest.raises(IBKRRequestTimeout) as excinfo:
372+
await ibkr.refresh_account_updates("ACC123")
373+
374+
assert "no usable account values" in str(excinfo.value)
375+
376+
377+
async def test_account_snapshot_ready_checks_for_non_zero_account_values(ibkr, mock_ib):
378+
"""Helper returns True only when tracked tags have non-zero data."""
379+
mock_ib.wrapper.accountValues = {
380+
("ACC123", "NetLiquidation", "USD", ""): AccountValue(
381+
"ACC123", "NetLiquidation", "0", "USD", ""
382+
)
383+
}
384+
385+
assert ibkr._account_snapshot_ready("ACC123") is False
386+
387+
mock_ib.wrapper.accountValues = {
388+
("ACC123", "NetLiquidation", "USD", ""): AccountValue(
389+
"ACC123", "NetLiquidation", "100000", "USD", ""
390+
)
391+
}
392+
393+
assert ibkr._account_snapshot_ready("ACC123") is True
394+
395+
396+
async def test_account_snapshot_ready_ignores_other_accounts_and_tags(ibkr, mock_ib):
397+
"""Values for other accounts or untracked tags should not mark snapshot ready."""
398+
mock_ib.wrapper.accountValues = {
399+
("OTHER", "NetLiquidation", "USD", ""): AccountValue(
400+
"OTHER", "NetLiquidation", "100000", "USD", ""
401+
),
402+
("ACC123", "GrossPositionValue", "USD", ""): AccountValue(
403+
"ACC123", "GrossPositionValue", "5000", "USD", ""
404+
),
405+
}
406+
407+
assert ibkr._account_snapshot_ready("ACC123") is False
408+
409+
410+
async def test_account_snapshot_ready_handles_missing_wrapper_or_values(ibkr, mock_ib):
411+
"""Return False when wrapper or accountValues are absent."""
412+
mock_ib.wrapper.accountValues = {}
413+
assert ibkr._account_snapshot_ready("ACC123") is False
414+
415+
mock_ib.wrapper = None
416+
assert ibkr._account_snapshot_ready("ACC123") is False
417+
418+
419+
async def test_account_value_has_data_true_for_non_zero_numeric(ibkr):
420+
"""Helper treats any non-zero numeric string as usable data."""
421+
value = AccountValue("ACC123", "NetLiquidation", "123.45", "USD", "")
422+
assert ibkr._account_value_has_data(value) is True
423+
424+
425+
@pytest.mark.parametrize("raw_value", ["0", "0.0", "", None, "abc"])
426+
async def test_account_value_has_data_false_for_invalid_inputs(ibkr, raw_value):
427+
"""Helper rejects zero, empty, None, and non-numeric values."""
428+
value = SimpleNamespace(value=raw_value)
429+
430+
assert ibkr._account_value_has_data(value) is False
431+
432+
320433
async def test_await_with_timeout_wraps_timeout_error(ibkr, mocker):
321434
"""_await_with_timeout raises IBKRRequestTimeout on asyncio timeout."""
322435

thetagang/ibkr.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ def __init__(self, description: str, timeout_seconds: int) -> None:
4949

5050

5151
class IBKR:
52+
ACCOUNT_VALUE_HEALTH_TAGS = {"NetLiquidation", "TotalCashValue", "BuyingPower"}
53+
5254
def __init__(
5355
self, ib: IB, api_response_wait_time: int, default_order_exchange: str
5456
) -> None:
@@ -93,9 +95,29 @@ def cancel_order(self, order: Order) -> None:
9395
self.ib.cancelOrder(order)
9496

9597
async def refresh_account_updates(self, account: str) -> None:
96-
await self._await_with_timeout(
97-
self.ib.reqAccountUpdatesAsync(account), "account updates"
98-
)
98+
if self._account_snapshot_ready(account):
99+
log.info(
100+
f"{account}: Account snapshot already populated, skipping refresh wait."
101+
)
102+
return
103+
104+
try:
105+
await self._await_with_timeout(
106+
self.ib.reqAccountUpdatesAsync(account), "account updates"
107+
)
108+
except IBKRRequestTimeout:
109+
if self._account_snapshot_ready(account):
110+
log.info(
111+
f"{account}: Account snapshot populated while waiting for account updates."
112+
)
113+
return
114+
raise
115+
116+
if not self._account_snapshot_ready(account):
117+
raise IBKRRequestTimeout(
118+
"account updates (no usable account values)",
119+
self.api_response_wait_time,
120+
)
99121

100122
async def refresh_positions(self) -> List[Position]:
101123
return await self._await_with_timeout(
@@ -280,6 +302,39 @@ async def _await_with_timeout(self, awaitable: Awaitable[T], description: str) -
280302
except asyncio.TimeoutError as exc:
281303
raise IBKRRequestTimeout(description, self.api_response_wait_time) from exc
282304

305+
def _account_snapshot_ready(self, account: str) -> bool:
306+
"""Return True if IB has populated non-zero account values for account."""
307+
wrapper = getattr(self.ib, "wrapper", None)
308+
if wrapper is None:
309+
return False
310+
311+
values_dict = getattr(wrapper, "accountValues", None)
312+
if not values_dict:
313+
return False
314+
315+
for value in values_dict.values():
316+
if (
317+
value.account != account
318+
or value.tag not in self.ACCOUNT_VALUE_HEALTH_TAGS
319+
):
320+
continue
321+
322+
if self._account_value_has_data(value):
323+
return True
324+
325+
return False
326+
327+
@staticmethod
328+
def _account_value_has_data(value: AccountValue) -> bool:
329+
raw_value = getattr(value, "value", None)
330+
if raw_value in (None, ""):
331+
return False
332+
333+
try:
334+
return float(raw_value) != 0.0
335+
except (TypeError, ValueError):
336+
return False
337+
283338
async def __market_data_streaming_handler__(
284339
self,
285340
contract: Contract,

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)