# 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 . ''' 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), ))