Add `is_expired()` and harden `.ib.venues` helpers

Add an expiry-date predicate and guard venue session
lookups against expired contracts and empty session
lists in `.ib.venues`; use in `api.py` to skip gap
detection for expired tracts.

Deats,
- add `is_expired()` predicate using
  `pendulum.parse()` on `realExpirationDate`.
- `sesh_times()`: raise `ValueError` if contract is
  expired or has no session intervals (instead of
  `StopIteration` from `next(iter(...))`).
- `is_venue_closure()`: handle `None` return from
  `sesh_times()` with guard + `breakpoint()`.

Also in `api.py`,
- import and call `is_expired()` from `.venues`.
- gate gap-detection on `not _is_expired`.
- default `timeZoneId` to `'EST'` when IB returns
  empty/`None`.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
repair_tests
Gud Boi 2026-03-20 16:42:36 -04:00
parent 08d159e652
commit c04cc0e87f
2 changed files with 45 additions and 3 deletions

View File

@ -95,6 +95,7 @@ from .symbols import (
) )
from ...log import get_logger from ...log import get_logger
from .venues import ( from .venues import (
is_expired,
is_venue_open, is_venue_open,
sesh_times, sesh_times,
is_venue_closure, is_venue_closure,
@ -496,7 +497,7 @@ class Client:
await self.ib.reqContractDetailsAsync(contract) await self.ib.reqContractDetailsAsync(contract)
)[0] )[0]
# convert to makt-native tz # convert to makt-native tz
tz: str = details.timeZoneId tz: str = details.timeZoneId or 'EST'
end_dt = end_dt.in_tz(tz) end_dt = end_dt.in_tz(tz)
first_dt: DateTime = from_timestamp(first).in_tz(tz) first_dt: DateTime = from_timestamp(first).in_tz(tz)
last_dt: DateTime = from_timestamp(last).in_tz(tz) last_dt: DateTime = from_timestamp(last).in_tz(tz)
@ -508,10 +509,18 @@ class Client:
_open_now: bool = is_venue_open( _open_now: bool = is_venue_open(
con_deats=details, con_deats=details,
) )
_is_expired: bool = is_expired(
con_deats=details,
)
# XXX, do gap detections. # XXX, do gap detections.
has_closure_gap: bool = False has_closure_gap: bool = False
if ( if (
# XXX, expired tracts can't be introspected
# for open/closure intervals due to ib's chitty
# details seemingly..
not _is_expired
and
last_dt.add(seconds=sample_period_s) last_dt.add(seconds=sample_period_s)
< <
end_dt end_dt

View File

@ -34,6 +34,7 @@ from typing import (
import exchange_calendars as xcals import exchange_calendars as xcals
from pendulum import ( from pendulum import (
parse,
now, now,
Duration, Duration,
Interval, Interval,
@ -56,6 +57,17 @@ if TYPE_CHECKING:
) )
def is_expired(
con_deats: ContractDetails,
) -> bool:
'''
Predicate
'''
expiry_dt: datetime = parse(con_deats.realExpirationDate)
return expiry_dt.date() >= now().date()
def has_weekend( def has_weekend(
period: Interval, period: Interval,
) -> bool: ) -> bool:
@ -170,7 +182,22 @@ def sesh_times(
get the (day-agnostic) times for the start/end. get the (day-agnostic) times for the start/end.
''' '''
earliest_sesh: Interval = next(iter_sessions(con_deats)) # ?TODO, lookup the next front contract instead?
if is_expired(con_deats):
raise ValueError(
f'Contract is already expired!\n'
f'Choose an active alt contract instead.\n'
f'con_deats: {con_deats!r}\n'
)
maybe_sessions: list[Interval] = list(iter_sessions(con_deats))
if not maybe_sessions:
raise ValueError(
f'Contract has no trading-session info?\n'
f'con_deats: {con_deats!r}\n'
)
earliest_sesh: Interval = maybe_sessions[0]
return ( return (
earliest_sesh.start.time(), earliest_sesh.start.time(),
earliest_sesh.end.time(), earliest_sesh.end.time(),
@ -211,7 +238,13 @@ def is_venue_closure(
''' '''
open: Time open: Time
close: Time close: Time
open, close = sesh_times(con_deats) maybe_oc: tuple|None = sesh_times(con_deats)
if maybe_oc is None:
# XXX, should never get here.
breakpoint()
return False
open, close = maybe_oc
# ensure times are in mkt-native timezone # ensure times are in mkt-native timezone
tz: str = con_deats.timeZoneId tz: str = con_deats.timeZoneId