From 50011d33efcb2d56330b47391243f9f9ed6f4ab5 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 9 Feb 2026 18:30:48 -0500 Subject: [PATCH] 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 --- piker/brokers/ib/api.py | 92 ++++++++++++++++++-------------------- piker/brokers/ib/venues.py | 91 ++++++++++++++++++++++++++++++++++--- 2 files changed, 129 insertions(+), 54 deletions(-) diff --git a/piker/brokers/ib/api.py b/piker/brokers/ib/api.py index 02e26a34..adb1bb89 100644 --- a/piker/brokers/ib/api.py +++ b/piker/brokers/ib/api.py @@ -92,10 +92,15 @@ from .symbols import ( _exch_skip_list, _futes_venues, ) -from ._util import ( - log, - # only for the ib_sync internal logging - get_logger, +from ...log import get_logger +from .venues import ( + is_venue_open, + sesh_times, + is_venue_closure, +) + +log = get_logger( + name=__name__, ) _bar_load_dtype: list[tuple[str, type]] = [ @@ -181,7 +186,7 @@ class NonShittyIB(IB): # override `ib_insync` internal loggers so we can see wtf # it's doing.. self._logger = get_logger( - 'ib_insync.ib', + name=__name__, ) self._createEvents() @@ -189,7 +194,7 @@ class NonShittyIB(IB): self.wrapper = NonShittyWrapper(self) self.client = ib_client.Client(self.wrapper) self.client._logger = get_logger( - 'ib_insync.client', + name='ib_insync.client', ) # self.errorEvent += self._onError @@ -486,64 +491,52 @@ class Client: last: float = times[-1] # frame_dur: float = times[-1] - first - first_dt: DateTime = from_timestamp(first) - last_dt: DateTime = from_timestamp(last) + details: ContractDetails = ( + 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 = ( last_dt - first_dt ).in_seconds() + sample_period_s + _open_now: bool = is_venue_open( + con_deats=details, + ) # XXX, do gap detections. + has_closure_gap: bool = False if ( last_dt.add(seconds=sample_period_s) < 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) # XXX, always calc gap in mkt-venue-local timezone - tz: str = details.timeZoneId - gap: Interval = ( - end_dt.in_tz(tz) - - - last_dt.in_tz(tz) - ) - - 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() + gap: Interval = end_dt - last_dt + if not ( + has_closure_gap := is_venue_closure( + gap=gap, + con_deats=details, + time_step_s=sample_period_s, + )): log.warning( f'Invalid non-closure gap for {fqme!r} ?!?\n' f'is-open-now: {_open_now}\n' f'\n' f'{gap}\n' ) - else: - assert is_venue_closure( - gap=gap, - con_deats=details, + log.warning( + f'Detected NON venue-closure GAP ??\n' + f'{gap}\n' ) + breakpoint() + else: + assert has_closure_gap log.debug( f'Detected venue closure gap (weekend),\n' f'{gap}\n' @@ -551,14 +544,14 @@ class Client: if ( start_dt is None + and ( + tdiff + < + dt_duration.in_seconds() + ) and - tdiff - < - dt_duration.in_seconds() - # and - # len(bars) * sample_period_s) < dt_duration.in_seconds() + not has_closure_gap ): - end_dt: DateTime = from_timestamp(first) log.error( f'Frame result was shorter then {dt_duration}!?\n' f'end_dt: {end_dt}\n' @@ -566,6 +559,7 @@ class Client: # f'\n' # f'Recursing for more bars:\n' ) + # XXX, debug! breakpoint() # XXX ? TODO? recursively try to re-request? # => i think *NO* right? diff --git a/piker/brokers/ib/venues.py b/piker/brokers/ib/venues.py index 3d9bf4a1..7f73af77 100644 --- a/piker/brokers/ib/venues.py +++ b/piker/brokers/ib/venues.py @@ -32,6 +32,7 @@ from typing import ( TYPE_CHECKING, ) +import exchange_calendars as xcals from pendulum import ( now, Duration, @@ -44,11 +45,24 @@ if TYPE_CHECKING: TradingSession, ContractDetails, ) + from exchange_calendars.exchange_calendars import ( + ExchangeCalendar, + ) + from pandas import ( + # DatetimeIndex, + TimeDelta, + Timestamp, + ) def has_weekend( period: Interval, ) -> bool: + ''' + Predicate to for a period being within + days 6->0 (sat->sun). + + ''' has_weekend: bool = False for dt in period: if dt.day_of_week in [0, 6]: # 0=Sunday, 6=Saturday @@ -58,6 +72,55 @@ def 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( sesh: Interval, when: datetime|None = None, @@ -126,6 +189,7 @@ def is_venue_open( def is_venue_closure( gap: Interval, con_deats: ContractDetails, + time_step_s: int, ) -> bool: ''' Check if a provided time-`gap` is just an (expected) trading @@ -135,19 +199,36 @@ def is_venue_closure( open: Time close: Time 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 ( ( - gap.start.time() == close - and - gap.end.time() == open + start_t in ( + close, + close.subtract(seconds=time_step_s) + ) + and + end_t in ( + open, + open.add(seconds=time_step_s), + ) ) or has_weekend(gap) + or + has_holiday( + con_deats=con_deats, + period=gap, + ) ): return True + # breakpoint() return False