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
parent
eb516c4c33
commit
341a584cea
|
|
@ -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
|
and (
|
||||||
tdiff
|
tdiff
|
||||||
<
|
<
|
||||||
dt_duration.in_seconds()
|
dt_duration.in_seconds()
|
||||||
# and
|
)
|
||||||
# len(bars) * sample_period_s) < dt_duration.in_seconds()
|
and
|
||||||
|
not has_closure_gap
|
||||||
):
|
):
|
||||||
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?
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
close,
|
||||||
|
close.subtract(seconds=time_step_s)
|
||||||
|
)
|
||||||
and
|
and
|
||||||
gap.end.time() == open
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue