piker/tests/providers/test_ib_schedule.py

176 lines
5.5 KiB
Python

# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
Historical schedule normalization and request-boundary regressions.
'''
import asyncio
from datetime import UTC, datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from piker.brokers.ib.venues import normalize_schedule
from piker.brokers.ib.api import Client
def response(zone='America/New_York'):
'''
Describe two sessions separated by a maintenance closure.
'''
return SimpleNamespace(
timeZone=zone,
startDateTime='20260923-00:00:00',
endDateTime='20260925-00:00:00',
sessions=[SimpleNamespace(
startDateTime='20260923-18:00:00',
endDateTime='20260924-17:00:00',
)],
)
def test_schedule_uses_dated_timezone_and_coverage():
'''
Historical allocation must use dated sessions, not today's hours.
A New York September session must convert using EDT and preserve
the response's wider coverage separately from its open interval.
'''
out = normalize_schedule(response(), False)
expected = datetime(2026, 9, 23, 22, tzinfo=UTC).timestamp()
assert out['sessions'][0][0] == expected
assert out['start'] < expected < out['end']
assert out['use_rth'] is False
@pytest.mark.parametrize('stamp', [
'20261101-01:30:00', '20260308-02:30:00',
])
def test_schedule_rejects_ambiguous_or_nonexistent_time(stamp):
'''
A guessed DST fold can shift reservations by an hour.
Reject both the repeated autumn hour and nonexistent spring
hour instead of turning unknown coverage into a false closure.
'''
schedule = response()
schedule.startDateTime = stamp
with pytest.raises(ValueError, match='Ambiguous'):
normalize_schedule(schedule, False)
def test_schedule_request_uses_extended_hours_and_utc():
'''
Schedule requests must match the bars endpoint's extended hours.
Exercise the real Client method with a fake IB transport. Assert
exact UTC request bounds and day rounding, and ensure response
coverage is not replaced by the caller's requested interval.
'''
calls = []
async def find_contracts(fqme):
assert fqme == 'mnq.cme.20261218'
return ['contract']
async def schedule(contract, **kwargs):
calls.append((contract, kwargs))
return response()
client = SimpleNamespace(
find_contracts=find_contracts,
ib=SimpleNamespace(reqHistoricalScheduleAsync=schedule),
)
out = asyncio.run(Client.history_schedule(
client, 'mnq.cme.20261218',
datetime(2026, 9, 23, 0, tzinfo=UTC),
datetime(2026, 9, 24, 1, tzinfo=UTC),
))
assert calls == [('contract', {
'numDays': 2, 'endDateTime': '20260924-01:00:00',
'useRTH': False,
})]
assert out['timezone'] == 'America/New_York'
def test_schedule_timeout_is_not_an_empty_session_list():
'''
A failed schedule request must not classify the range as closed.
Block the simulated provider and apply an immediate timeout;
the adapter must propagate failure instead of returning coverage.
'''
async def find_contracts(fqme):
return ['contract']
async def schedule(*args, **kwargs):
await asyncio.sleep(100)
client = SimpleNamespace(
find_contracts=find_contracts,
ib=SimpleNamespace(reqHistoricalScheduleAsync=schedule),
)
with pytest.raises(TimeoutError):
asyncio.run(Client.history_schedule(
client, 'mnq.cme',
datetime(2026, 9, 23, tzinfo=UTC),
datetime(2026, 9, 24, tzinfo=UTC),
timeout_s=0,
))
def test_schedule_rejects_reversed_session():
'''
A malformed session must not silently become a venue closure.
Reject the response instead of dropping its reversed interval
and reserving slots using the remaining incomplete session list.
'''
schedule = response()
schedule.sessions[0].endDateTime = '20260923-17:00:00'
with pytest.raises(ValueError, match='Invalid schedule session'):
normalize_schedule(schedule, False)
def test_schedule_duration_counts_elapsed_time_across_dst():
'''
The autumn clock change makes this local day last 25 hours.
Request two elapsed days so duration rounding cannot leave an
hour uncovered by subtracting same-zone wall-clock datetimes.
'''
async def find_contracts(fqme):
return ['contract']
async def schedule(contract, **kwargs):
assert kwargs['numDays'] == 2
return response()
client = SimpleNamespace(
find_contracts=find_contracts,
ib=SimpleNamespace(reqHistoricalScheduleAsync=schedule),
)
zone = ZoneInfo('America/New_York')
asyncio.run(Client.history_schedule(
client, 'mnq.cme',
datetime(2026, 11, 1, tzinfo=zone),
datetime(2026, 11, 2, tzinfo=zone),
))