Add holiday-gap detection via `exchange_calendars`

Integrate `exchange_calendars` lib to detect market holidays in
gap-checking logic via new `.ib.venues.has_holiday()` helper!

The `.ib.venues` impl deats,
- add  a new `has_holiday()` using `xcals.get_calendar()` and friends
  for sanity checking a venue's holiday closure-gaps.
  * final holiday detection-check is basically,
   `(cash_gap := (next_open - prev_close)) > period`
- include `time_step_s` param to `is_venue_closure()` for boundary
  tolerance checks.
  * let's us expand closure-time checks to include `+/-time_step_s`
    "off-by-one-`timeframe`-sample" edge case ranges.
- add real docstring to `has_weekend()`.

In `.ib.api` refine usage for ^ changes,
- move `is_venue_open()` call + tz-convert outside gap check
- use a walrus to capture `has_closure_gap` from `is_venue_closure()`
- add a `not has_closure_gap` condition to the
  mismatched-duration/short-frame warning block to avoid needless warns.
- keep duration-based "short-frame" log as `.error()` but toss in a bp
  so (somone can) umask to figure out wtf is going on..
  * we should **never** really hit this path unless there's a valid bug
    or data issue with IB/GFIS!
  * keep recursion path masked-out just leave a `breakpoint()` for now.

Also some logger updates,
- import `get_logger()` from top-level `piker.log` vs `.ib._util` which
  was always kinda wrong..
- change `NonShittyIB._logger` to use `__name__` vs literal.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
Gud Boi 2026-02-09 18:30:48 -05:00
parent 417e9c6375
commit b2b180428b
2 changed files with 129 additions and 54 deletions

View File

@ -92,10 +92,15 @@ from .symbols import (
_exch_skip_list, _exch_skip_list,
_futes_venues, _futes_venues,
) )
from ._util import ( from ...log import get_logger
log, from .venues import (
# only for the ib_sync internal logging is_venue_open,
get_logger, sesh_times,
is_venue_closure,
)
log = get_logger(
name=__name__,
) )
_bar_load_dtype: list[tuple[str, type]] = [ _bar_load_dtype: list[tuple[str, type]] = [
@ -181,7 +186,7 @@ class NonShittyIB(IB):
# override `ib_insync` internal loggers so we can see wtf # override `ib_insync` internal loggers so we can see wtf
# it's doing.. # it's doing..
self._logger = get_logger( self._logger = get_logger(
'ib_insync.ib', name=__name__,
) )
self._createEvents() self._createEvents()
@ -189,7 +194,7 @@ class NonShittyIB(IB):
self.wrapper = NonShittyWrapper(self) self.wrapper = NonShittyWrapper(self)
self.client = ib_client.Client(self.wrapper) self.client = ib_client.Client(self.wrapper)
self.client._logger = get_logger( self.client._logger = get_logger(
'ib_insync.client', name='ib_insync.client',
) )
# self.errorEvent += self._onError # self.errorEvent += self._onError
@ -486,64 +491,52 @@ class Client:
last: float = times[-1] last: float = times[-1]
# frame_dur: float = times[-1] - first # frame_dur: float = times[-1] - first
first_dt: DateTime = from_timestamp(first) details: ContractDetails = (
last_dt: DateTime = from_timestamp(last) await self.ib.reqContractDetailsAsync(contract)
)[0]
# convert to makt-native tz
tz: str = details.timeZoneId
end_dt = end_dt.in_tz(tz)
first_dt: DateTime = from_timestamp(first).in_tz(tz)
last_dt: DateTime = from_timestamp(last).in_tz(tz)
tdiff: int = ( tdiff: int = (
last_dt last_dt
- -
first_dt first_dt
).in_seconds() + sample_period_s ).in_seconds() + sample_period_s
_open_now: bool = is_venue_open(
con_deats=details,
)
# XXX, do gap detections. # XXX, do gap detections.
has_closure_gap: bool = False
if ( if (
last_dt.add(seconds=sample_period_s) last_dt.add(seconds=sample_period_s)
< <
end_dt end_dt
): ):
details: ContractDetails = (
await self.ib.reqContractDetailsAsync(contract)
)[0]
from .venues import (
is_venue_open,
has_weekend,
sesh_times,
is_venue_closure,
)
_open_now: bool = is_venue_open(
con_deats=details,
)
open_time, close_time = sesh_times(details) open_time, close_time = sesh_times(details)
# XXX, always calc gap in mkt-venue-local timezone # XXX, always calc gap in mkt-venue-local timezone
tz: str = details.timeZoneId gap: Interval = end_dt - last_dt
gap: Interval = ( if not (
end_dt.in_tz(tz) has_closure_gap := is_venue_closure(
- gap=gap,
last_dt.in_tz(tz) con_deats=details,
) time_step_s=sample_period_s,
)):
if (
not has_weekend(gap)
and
# XXX NOT outside venue closures.
# !TODO, replace with,
# `not is_venue_closure()`
# per below assert on inverse case!
gap.end.time() != open_time
and
gap.start.time() != close_time
):
breakpoint()
log.warning( log.warning(
f'Invalid non-closure gap for {fqme!r} ?!?\n' f'Invalid non-closure gap for {fqme!r} ?!?\n'
f'is-open-now: {_open_now}\n' f'is-open-now: {_open_now}\n'
f'\n' f'\n'
f'{gap}\n' f'{gap}\n'
) )
else: log.warning(
assert is_venue_closure( f'Detected NON venue-closure GAP ??\n'
gap=gap, f'{gap}\n'
con_deats=details,
) )
breakpoint()
else:
assert has_closure_gap
log.debug( log.debug(
f'Detected venue closure gap (weekend),\n' f'Detected venue closure gap (weekend),\n'
f'{gap}\n' f'{gap}\n'
@ -551,14 +544,14 @@ class Client:
if ( if (
start_dt is None start_dt is None
and (
tdiff
<
dt_duration.in_seconds()
)
and and
tdiff not has_closure_gap
<
dt_duration.in_seconds()
# and
# len(bars) * sample_period_s) < dt_duration.in_seconds()
): ):
end_dt: DateTime = from_timestamp(first)
log.error( log.error(
f'Frame result was shorter then {dt_duration}!?\n' f'Frame result was shorter then {dt_duration}!?\n'
f'end_dt: {end_dt}\n' f'end_dt: {end_dt}\n'
@ -566,6 +559,7 @@ class Client:
# f'\n' # f'\n'
# f'Recursing for more bars:\n' # f'Recursing for more bars:\n'
) )
# XXX, debug!
breakpoint() breakpoint()
# XXX ? TODO? recursively try to re-request? # XXX ? TODO? recursively try to re-request?
# => i think *NO* right? # => i think *NO* right?

View File

@ -32,6 +32,7 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
import exchange_calendars as xcals
from pendulum import ( from pendulum import (
now, now,
Duration, Duration,
@ -44,11 +45,24 @@ if TYPE_CHECKING:
TradingSession, TradingSession,
ContractDetails, ContractDetails,
) )
from exchange_calendars.exchange_calendars import (
ExchangeCalendar,
)
from pandas import (
# DatetimeIndex,
TimeDelta,
Timestamp,
)
def has_weekend( def has_weekend(
period: Interval, period: Interval,
) -> bool: ) -> bool:
'''
Predicate to for a period being within
days 6->0 (sat->sun).
'''
has_weekend: bool = False has_weekend: bool = False
for dt in period: for dt in period:
if dt.day_of_week in [0, 6]: # 0=Sunday, 6=Saturday if dt.day_of_week in [0, 6]: # 0=Sunday, 6=Saturday
@ -58,6 +72,55 @@ def has_weekend(
return has_weekend return has_weekend
def has_holiday(
con_deats: ContractDetails,
period: Interval,
) -> bool:
'''
Using the `exchange_calendars` lib detect if a time-gap `period`
is contained in a known "cash hours" closure.
'''
tz: str = con_deats.timeZoneId
exch: str = con_deats.contract.primaryExchange
cal: ExchangeCalendar = xcals.get_calendar(exch)
end: datetime = period.end
# _start: datetime = period.start
# ?TODO, can rm ya?
# => not that useful?
# dti: DatetimeIndex = cal.sessions_in_range(
# _start.date(),
# end.date(),
# )
prev_close: Timestamp = cal.previous_close(
end.date()
).tz_convert(tz)
prev_open: Timestamp = cal.previous_open(
end.date()
).tz_convert(tz)
# now do relative from prev_ values ^
# to get the next open which should match
# "contain" the end of the gap.
next_open: Timestamp = cal.next_open(
prev_open,
).tz_convert(tz)
next_open: Timestamp = cal.next_open(
prev_open,
).tz_convert(tz)
_next_close: Timestamp = cal.next_close(
prev_close
).tz_convert(tz)
cash_gap: TimeDelta = next_open - prev_close
is_holiday_gap = (
cash_gap
>
period
)
# XXX, debug
# breakpoint()
return is_holiday_gap
def is_current_time_in_range( def is_current_time_in_range(
sesh: Interval, sesh: Interval,
when: datetime|None = None, when: datetime|None = None,
@ -126,6 +189,7 @@ def is_venue_open(
def is_venue_closure( def is_venue_closure(
gap: Interval, gap: Interval,
con_deats: ContractDetails, con_deats: ContractDetails,
time_step_s: int,
) -> bool: ) -> bool:
''' '''
Check if a provided time-`gap` is just an (expected) trading Check if a provided time-`gap` is just an (expected) trading
@ -135,19 +199,36 @@ def is_venue_closure(
open: Time open: Time
close: Time close: Time
open, close = sesh_times(con_deats) open, close = sesh_times(con_deats)
# TODO! ensure this works!
# breakpoint() # ensure times are in mkt-native timezone
tz: str = con_deats.timeZoneId
start = gap.start.in_tz(tz)
start_t = start.time()
end = gap.end.in_tz(tz)
end_t = end.time()
if ( if (
( (
gap.start.time() == close start_t in (
and close,
gap.end.time() == open close.subtract(seconds=time_step_s)
)
and
end_t in (
open,
open.add(seconds=time_step_s),
)
) )
or or
has_weekend(gap) has_weekend(gap)
or
has_holiday(
con_deats=con_deats,
period=gap,
)
): ):
return True return True
# breakpoint()
return False return False