176 lines
5.5 KiB
Python
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),
|
|
))
|