589 lines
15 KiB
Python
589 lines
15 KiB
Python
'''
|
|
IB historical-data request regressions.
|
|
|
|
'''
|
|
from datetime import (
|
|
UTC,
|
|
datetime,
|
|
)
|
|
from types import SimpleNamespace
|
|
|
|
from ib_async import RequestError
|
|
import numpy as np
|
|
from pendulum import (
|
|
datetime as pdatetime,
|
|
duration,
|
|
)
|
|
import pytest
|
|
import trio
|
|
|
|
from piker.brokers import DataUnavailable
|
|
from piker.brokers.ib import feed as ib_feed
|
|
from piker.brokers.ib.api import Client
|
|
from piker.brokers.ib.feed import get_bars
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('end_dt', 'expected'),
|
|
[
|
|
(None, ''),
|
|
(
|
|
datetime(2026, 7, 28, 23, 30, tzinfo=UTC),
|
|
'20260728-23:30:00',
|
|
),
|
|
(
|
|
pdatetime(2026, 7, 28, 23, 30, tz='UTC'),
|
|
'20260728-23:30:00',
|
|
),
|
|
(
|
|
pdatetime(
|
|
2026,
|
|
7,
|
|
28,
|
|
23,
|
|
30,
|
|
tz='America/New_York',
|
|
),
|
|
'20260729-03:30:00',
|
|
),
|
|
],
|
|
)
|
|
def test_history_end_datetime_uses_ib_utc_format(
|
|
end_dt: datetime|None,
|
|
expected: str,
|
|
) -> None:
|
|
'''
|
|
Reverse history boundaries must use IB's explicit UTC syntax.
|
|
|
|
``ib_async`` adds a trailing ``UTC`` zone to aware datetimes.
|
|
The live IB gateway rejected that form with error 10314 while
|
|
filling an MNQ gap. Invoke ``Client.bars()`` with stdlib and
|
|
Pendulum boundaries, then capture the request kwargs.
|
|
Prove each becomes IB's UTC-dash form while latest requests stay
|
|
blank.
|
|
|
|
'''
|
|
request: dict = {}
|
|
|
|
class FakeIB:
|
|
async def reqHistoricalDataAsync(self, **kwargs) -> list:
|
|
request.update(kwargs)
|
|
return []
|
|
|
|
client = SimpleNamespace(ib=FakeIB())
|
|
|
|
async def find_contracts(fqme: str) -> list:
|
|
return [SimpleNamespace()]
|
|
|
|
client.find_contracts = find_contracts
|
|
|
|
async def main() -> None:
|
|
await Client.bars(
|
|
client,
|
|
fqme='mnq.cme.20260918',
|
|
sample_period_s=60,
|
|
end_dt=end_dt,
|
|
)
|
|
|
|
trio.run(main)
|
|
assert request['endDateTime'] == expected
|
|
|
|
|
|
def test_empty_history_frame_completes() -> None:
|
|
'''
|
|
A blank IB response must release the history request slot.
|
|
|
|
``get_bars()`` previously returned from its query task without
|
|
signalling the parent, which then reset the feed forever. That
|
|
kept serialized SHM repair from running. Return IB's empty
|
|
response and prove the request completes explicitly
|
|
with no result instead of relying on a reset timeout.
|
|
|
|
'''
|
|
class Proxy:
|
|
'''
|
|
Return one empty IB history response.
|
|
|
|
'''
|
|
async def bars(self, **kwargs):
|
|
'''
|
|
Model ``Client.bars()`` during a venue data gap.
|
|
|
|
'''
|
|
return (
|
|
[],
|
|
np.empty(0),
|
|
duration(seconds=2000),
|
|
)
|
|
|
|
async def main() -> None:
|
|
with trio.fail_after(0.1):
|
|
result, timedout = await get_bars(
|
|
Proxy(),
|
|
'qqq.nasdaq.ib',
|
|
1,
|
|
)
|
|
assert result is None
|
|
assert not timedout
|
|
|
|
trio.run(main)
|
|
|
|
|
|
def test_repeated_ib_cancellation_is_bounded() -> None:
|
|
'''
|
|
Repeated IB cancellation must not monopolize history forever.
|
|
|
|
IB can answer a historical request with error 162 cancellation.
|
|
The old retry loop had no limit, so reverse backfill could retain
|
|
the backend's sole request slot and prevent final null repair.
|
|
Always raise that response and prove the configured retry bound
|
|
terminates with ``DataUnavailable`` deterministically.
|
|
|
|
'''
|
|
class Proxy:
|
|
'''
|
|
Cancel every IB history request.
|
|
|
|
'''
|
|
async def bars(self, **kwargs):
|
|
'''
|
|
Raise IB's historical-query cancellation response.
|
|
|
|
'''
|
|
raise RequestError(
|
|
1,
|
|
162,
|
|
'API historical data query cancelled',
|
|
)
|
|
|
|
async def main() -> None:
|
|
with (
|
|
trio.fail_after(0.1),
|
|
pytest.raises(DataUnavailable),
|
|
):
|
|
await get_bars(
|
|
Proxy(),
|
|
'qqq.nasdaq.ib',
|
|
1,
|
|
max_failed_resets=2,
|
|
)
|
|
|
|
trio.run(main)
|
|
|
|
|
|
def test_query_error_cancels_stalled_reset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
'''
|
|
Exceptional history exit must cancel its in-flight reset task.
|
|
|
|
A timed-out request acquires the reset lock before launching
|
|
IB's VNC reset task. If the query then raises while that task is
|
|
blocked, stale work must not retain the reset lock. Force that
|
|
ordering with explicit Trio checkpoints, then prove bounded query
|
|
failure cancels the reset task before propagating.
|
|
|
|
'''
|
|
reset_cancelled: list[bool] = []
|
|
|
|
class Proxy:
|
|
'''
|
|
Delay once, then return IB cancellation.
|
|
|
|
'''
|
|
async def bars(self, **kwargs):
|
|
'''
|
|
Let the parent acquire the reset lock before failing.
|
|
|
|
'''
|
|
await trio.sleep(0.02)
|
|
raise RequestError(
|
|
1,
|
|
162,
|
|
'API historical data query cancelled',
|
|
)
|
|
|
|
async def stalled_reset(
|
|
proxy,
|
|
reset_type='data',
|
|
task_status=trio.TASK_STATUS_IGNORED,
|
|
) -> bool:
|
|
'''
|
|
Publish reset state, then block until sibling failure.
|
|
|
|
'''
|
|
done = trio.Event()
|
|
result: list[bool] = []
|
|
with trio.CancelScope() as cs:
|
|
task_status.started((cs, done, result))
|
|
try:
|
|
await trio.sleep_forever()
|
|
finally:
|
|
reset_cancelled.append(True)
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
ib_feed,
|
|
'wait_on_data_reset',
|
|
stalled_reset,
|
|
)
|
|
|
|
async def main() -> None:
|
|
with (
|
|
trio.fail_after(0.1),
|
|
pytest.raises(DataUnavailable),
|
|
):
|
|
await get_bars(
|
|
Proxy(),
|
|
'qqq.nasdaq.ib',
|
|
1,
|
|
feed_reset_timeout=0.001,
|
|
max_failed_resets=1,
|
|
)
|
|
|
|
trio.run(main)
|
|
assert reset_cancelled == [True]
|
|
|
|
|
|
def test_failed_pacing_resets_reach_retry_limit(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
'''
|
|
Failed pacing resets must increment the request's retry count.
|
|
|
|
The reset API returns an ``Event`` and result box. The old
|
|
code tested the always-truthy event and reset the failure counter
|
|
after each failure. Return immediate failures and prove two
|
|
pacing responses terminate instead of looping forever.
|
|
|
|
'''
|
|
reset_calls: list[bool] = []
|
|
|
|
class Proxy:
|
|
'''
|
|
Return a pacing violation for every request.
|
|
|
|
'''
|
|
_aio_ns = SimpleNamespace(
|
|
ib=SimpleNamespace(
|
|
client=SimpleNamespace(),
|
|
),
|
|
)
|
|
|
|
async def bars(self, **kwargs):
|
|
'''
|
|
Raise IB's pacing response.
|
|
|
|
'''
|
|
raise RequestError(
|
|
1,
|
|
162,
|
|
ib_feed._pacing,
|
|
)
|
|
|
|
async def failed_reset(
|
|
proxy,
|
|
reset_type='data',
|
|
task_status=trio.TASK_STATUS_IGNORED,
|
|
) -> bool:
|
|
'''
|
|
Publish one completed, unsuccessful reset.
|
|
|
|
'''
|
|
done = trio.Event()
|
|
result = [False]
|
|
reset_calls.append(True)
|
|
with trio.CancelScope() as cs:
|
|
task_status.started((cs, done, result))
|
|
done.set()
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
ib_feed,
|
|
'wait_on_data_reset',
|
|
failed_reset,
|
|
)
|
|
|
|
async def main() -> None:
|
|
with (
|
|
trio.fail_after(0.1),
|
|
pytest.raises(DataUnavailable),
|
|
):
|
|
await get_bars(
|
|
Proxy(),
|
|
'qqq.nasdaq.ib',
|
|
1,
|
|
max_failed_resets=2,
|
|
)
|
|
|
|
trio.run(main)
|
|
assert reset_calls == [True, True]
|
|
|
|
|
|
def test_pacing_resets_are_serialized_across_requests(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
'''
|
|
Concurrent history requests must never reset IB farms together.
|
|
|
|
Pacing recovery bypassed reset ownership, allowing 1s and 60s
|
|
requests to launch the VNC hack concurrently. Start two pacing
|
|
failures in one nursery and hold each fake reset across a Trio
|
|
checkpoint. Active-count assertions prove the actor-global lock
|
|
serializes reset lifetimes without depending on scheduler timing.
|
|
|
|
'''
|
|
active: int = 0
|
|
max_active: int = 0
|
|
reset_calls: int = 0
|
|
|
|
class Proxy:
|
|
'''
|
|
Return a pacing violation for every request.
|
|
|
|
'''
|
|
_aio_ns = SimpleNamespace(
|
|
ib=SimpleNamespace(
|
|
client=SimpleNamespace(),
|
|
),
|
|
)
|
|
|
|
async def bars(self, **kwargs):
|
|
'''
|
|
Raise IB's pacing response.
|
|
|
|
'''
|
|
raise RequestError(
|
|
1,
|
|
162,
|
|
ib_feed._pacing,
|
|
)
|
|
|
|
async def failed_reset(
|
|
proxy,
|
|
reset_type='data',
|
|
task_status=trio.TASK_STATUS_IGNORED,
|
|
) -> bool:
|
|
'''
|
|
Hold one failed reset long enough to expose overlap.
|
|
|
|
'''
|
|
nonlocal active, max_active, reset_calls
|
|
done = trio.Event()
|
|
result: list[bool] = []
|
|
with trio.CancelScope() as cs:
|
|
task_status.started((cs, done, result))
|
|
active += 1
|
|
reset_calls += 1
|
|
max_active = max(max_active, active)
|
|
await trio.sleep(0.01)
|
|
active -= 1
|
|
result.append(False)
|
|
done.set()
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
ib_feed,
|
|
'wait_on_data_reset',
|
|
failed_reset,
|
|
)
|
|
|
|
async def request() -> None:
|
|
'''
|
|
Run one independently bounded history request.
|
|
|
|
'''
|
|
with pytest.raises(DataUnavailable):
|
|
await get_bars(
|
|
Proxy(),
|
|
'qqq.nasdaq.ib',
|
|
1,
|
|
max_failed_resets=1,
|
|
)
|
|
|
|
async def main() -> None:
|
|
with trio.fail_after(0.1):
|
|
async with trio.open_nursery() as nursery:
|
|
nursery.start_soon(request)
|
|
nursery.start_soon(request)
|
|
|
|
trio.run(main)
|
|
assert reset_calls == 2
|
|
assert max_active == 1
|
|
|
|
|
|
def test_pacing_supersedes_timeout_reset_once(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
'''
|
|
Pacing handoff must not count cancellation as another failure.
|
|
|
|
A slow request can trigger timeout recovery immediately before IB
|
|
returns a pacing violation. Query-side recovery then deliberately
|
|
cancels that data reset and waits for the same actor-global lock.
|
|
Reproduce this with blocked first and failed second resets. A
|
|
one-attempt limit proves cancellation releases the lock
|
|
without aborting before the intended connection reset runs.
|
|
|
|
'''
|
|
reset_types: list[str] = []
|
|
|
|
class Proxy:
|
|
'''
|
|
Return pacing only after timeout recovery starts.
|
|
|
|
'''
|
|
_aio_ns = SimpleNamespace(
|
|
ib=SimpleNamespace(
|
|
client=SimpleNamespace(),
|
|
),
|
|
)
|
|
|
|
async def bars(self, **kwargs):
|
|
'''
|
|
Delay past the reset timeout, then report pacing.
|
|
|
|
'''
|
|
await trio.sleep(0.02)
|
|
raise RequestError(
|
|
1,
|
|
162,
|
|
ib_feed._pacing,
|
|
)
|
|
|
|
async def reset(
|
|
proxy,
|
|
reset_type='data',
|
|
task_status=trio.TASK_STATUS_IGNORED,
|
|
) -> bool:
|
|
'''
|
|
Block data reset and fail connection reset immediately.
|
|
|
|
'''
|
|
done = trio.Event()
|
|
result: list[bool] = []
|
|
reset_types.append(reset_type)
|
|
with trio.CancelScope() as cs:
|
|
task_status.started((cs, done, result))
|
|
if reset_type == 'data':
|
|
await trio.sleep_forever()
|
|
|
|
result.append(False)
|
|
done.set()
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
ib_feed,
|
|
'wait_on_data_reset',
|
|
reset,
|
|
)
|
|
|
|
async def main() -> None:
|
|
with (
|
|
trio.fail_after(0.1),
|
|
pytest.raises(DataUnavailable),
|
|
):
|
|
await get_bars(
|
|
Proxy(),
|
|
'qqq.nasdaq.ib',
|
|
1,
|
|
feed_reset_timeout=0.001,
|
|
max_failed_resets=1,
|
|
)
|
|
|
|
trio.run(main)
|
|
assert reset_types == ['data', 'connection']
|
|
|
|
|
|
def test_completed_pacing_reset_restarts_timeout_wait(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
'''
|
|
A queued timeout waiter must consume completed pacing recovery.
|
|
|
|
While query-side pacing recovery holds the reset lock, the outer
|
|
timeout task can already queue behind it. Without a completion
|
|
handoff, that waiter launches a redundant data reset immediately.
|
|
Return a blank frame just after a successful connection reset and
|
|
prove no third reset runs before the restarted timeout observes
|
|
completion.
|
|
|
|
'''
|
|
reset_types: list[str] = []
|
|
|
|
class Proxy:
|
|
'''
|
|
Pace the first query and return blank data on retry.
|
|
|
|
'''
|
|
_aio_ns = SimpleNamespace(
|
|
ib=SimpleNamespace(
|
|
client=SimpleNamespace(),
|
|
),
|
|
)
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: int = 0
|
|
|
|
async def bars(self, **kwargs):
|
|
'''
|
|
Control the pacing-to-success handoff ordering.
|
|
|
|
'''
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
await trio.sleep(0.02)
|
|
raise RequestError(
|
|
1,
|
|
162,
|
|
ib_feed._pacing,
|
|
)
|
|
|
|
await trio.sleep(0.0005)
|
|
return (
|
|
[],
|
|
np.empty(0),
|
|
duration(seconds=2000),
|
|
)
|
|
|
|
async def reset(
|
|
proxy,
|
|
reset_type='data',
|
|
task_status=trio.TASK_STATUS_IGNORED,
|
|
) -> bool:
|
|
'''
|
|
Cancel timeout recovery and complete pacing recovery.
|
|
|
|
'''
|
|
done = trio.Event()
|
|
result: list[bool] = []
|
|
reset_types.append(reset_type)
|
|
with trio.CancelScope() as cs:
|
|
task_status.started((cs, done, result))
|
|
if reset_type == 'data':
|
|
await trio.sleep_forever()
|
|
|
|
result.append(reset_type == 'connection')
|
|
done.set()
|
|
return result[0]
|
|
|
|
monkeypatch.setattr(
|
|
ib_feed,
|
|
'wait_on_data_reset',
|
|
reset,
|
|
)
|
|
|
|
async def main() -> None:
|
|
with trio.fail_after(0.1):
|
|
result, _ = await get_bars(
|
|
Proxy(),
|
|
'qqq.nasdaq.ib',
|
|
1,
|
|
feed_reset_timeout=0.001,
|
|
max_failed_resets=2,
|
|
)
|
|
assert result is None
|
|
|
|
trio.run(main)
|
|
assert reset_types == ['data', 'connection']
|