#!/usr/bin/env python3
# coding: utf-8
""" Vendor-agnostic intraday trading-session utilities.
Deterministic, dependency-free tools to tag epoch-second timestamps with an
exchange's regular trading hours: a boolean mask, a per-bar running session
id, first/last-bar bounds per session, and a helper to split an array into
one chunk per session.
**Scope**: timestamps are plain ``int64``/``float64`` NumPy arrays holding
epoch seconds (UTC) -- no ``pandas``, ``pytz`` or ``exchange_calendars``
dependency. The exchange clock is modelled as a single fixed ``utc_offset``
(hours). **Daylight-saving time is explicitly out of scope**: a fixed offset
cannot represent an exchange that observes DST (its local open/close would
drift by an hour twice a year). Users who need DST-correct sessions should
bring ``exchange_calendars`` (or similar) themselves and pre-shift their
timestamps, or call these functions twice with the two seasonal offsets. This
module is the deterministic, zero-dependency tool for the common 80% case: a
fixed-offset exchange (or an already DST-adjusted timestamp array).
"""
from __future__ import annotations
# Built-in packages
# Third-party packages
import numpy as np
from numpy.typing import NDArray
__all__ = ['session_mask', 'session_id', 'session_bounds', 'split_sessions']
_SECONDS_PER_DAY = 86400
# 1970-01-01 (epoch day 0) is a Thursday; datetime.date.weekday() convention
# (Monday=0 ... Sunday=6), so weekday = (epoch_day + 3) % 7.
_EPOCH_DAY_0_WEEKDAY_OFFSET = 3
_WEEKEND_CUTOFF = 5 # weekday >= 5 -> Saturday/Sunday
def _parse_hhmm(value: str) -> int:
""" Parse an ``"HH:MM"`` string into seconds since local midnight.
Parameters
----------
value : str
Time of day, e.g. ``"09:30"``.
Returns
-------
int
Seconds since local midnight, in ``[0, 86400)``.
Raises
------
ValueError
If ``value`` is not a well-formed ``"HH:MM"`` string with
``0 <= HH < 24`` and ``0 <= MM < 60``.
"""
parts = value.split(":")
if len(parts) != 2:
raise ValueError(f"invalid HH:MM time string: {value!r}")
try:
hour, minute = int(parts[0]), int(parts[1])
except ValueError as exc:
raise ValueError(f"invalid HH:MM time string: {value!r}") from exc
if not (0 <= hour < 24 and 0 <= minute < 60):
raise ValueError(f"invalid HH:MM time string: {value!r}")
return hour * 3600 + minute * 60
def _check_timestamps(ts: NDArray[np.int64] | NDArray[np.float64]) -> NDArray[np.float64]:
""" Validate ``ts`` and return it as a 1-D ``float64`` array.
Raises
------
ValueError
If ``ts`` is not 1-D, or is not non-decreasing.
"""
arr = np.asarray(ts)
if arr.ndim != 1:
raise ValueError(f"ts must be a 1-D array, got shape {arr.shape}")
if arr.size > 1 and np.any(np.diff(arr) < 0):
raise ValueError("ts must be non-decreasing")
return arr.astype(np.float64)
def _session_components(
ts: NDArray[np.int64] | NDArray[np.float64],
open: str,
close: str,
utc_offset: float,
weekdays_only: bool,
) -> tuple[NDArray[np.bool_], NDArray[np.int64]]:
""" Shared core: per-bar in-session flag and the session's opening day.
``open_day`` is the local epoch-day number (``local_seconds // 86400``,
epoch day 0 = Thursday 1970-01-01) on which the bar's session OPENED --
for a regular (same-day) session this is just the bar's own calendar day,
but for an overnight session (``close < open``) the post-midnight half of
the session still carries the PREVIOUS day's ``open_day``. This lets
:func:`session_id` group an overnight session's bars under one id, and
lets ``weekdays_only`` decide inclusion from the day the session opened
rather than the calendar day a given bar happens to fall on.
"""
arr = _check_timestamps(ts)
open_sod = _parse_hhmm(open)
close_sod = _parse_hhmm(close)
# Pure (float64, but integer-valued) arithmetic hot path: days = ts // 86400.
local = arr + utc_offset * 3600.0
days = np.floor(local / _SECONDS_PER_DAY).astype(np.int64)
seconds_of_day = local - days * float(_SECONDS_PER_DAY)
if open_sod < close_sod:
# Regular, same-day session: [open, close).
in_session = (seconds_of_day >= open_sod) & (seconds_of_day < close_sod)
open_day = days
else:
# Overnight session (close <= open, e.g. 18:00 -> 17:00): a bar either
# falls in the evening half [open, 24:00) of the day it opened, or in
# the morning half [00:00, close) that belongs to the PREVIOUS day's
# session. open_sod == close_sod is the degenerate 24h-session case
# (every bar matches one of the two halves).
evening = seconds_of_day >= open_sod
morning = seconds_of_day < close_sod
in_session = evening | morning
open_day = np.where(evening, days, days - 1)
if weekdays_only:
weekday = (open_day + _EPOCH_DAY_0_WEEKDAY_OFFSET) % 7
in_session = in_session & (weekday < _WEEKEND_CUTOFF)
return in_session, open_day
[docs]
def session_mask(
ts: NDArray[np.int64] | NDArray[np.float64],
open: str = "09:30",
close: str = "16:00",
utc_offset: float = 0.0,
weekdays_only: bool = True,
) -> NDArray[np.bool_]:
""" Flag timestamps that fall inside a trading session.
Parameters
----------
ts : ndarray of int64 or float64
Non-decreasing epoch-second (UTC) timestamps, shape ``(n,)``.
open, close : str, default "09:30", "16:00"
Local session open/close, ``"HH:MM"``. ``close < open`` (or
``close == open``) models an overnight session, e.g. ``"18:00"`` ->
``"17:00"``.
utc_offset : float, default 0.0
Fixed exchange-clock offset from UTC, in hours (e.g. ``-5.0`` for
NYSE standard time). A **single** fixed offset -- DST is out of
scope, see the module docstring.
weekdays_only : bool, default True
Exclude sessions that OPEN on a Saturday/Sunday (local calendar day
of the session's open, not of every individual bar -- see
:func:`session_id`).
Returns
-------
ndarray of bool
``True`` where ``ts`` falls in ``[open, close)`` local time (and, if
``weekdays_only``, the session opened on a weekday).
Raises
------
ValueError
If ``open``/``close`` are not well-formed ``"HH:MM"`` strings, ``ts``
is not 1-D, or ``ts`` is not non-decreasing.
Examples
--------
>>> import numpy as np
>>> from fynance.data.sessions import session_mask
>>> ts = np.array([9 * 3600, 10 * 3600, 17 * 3600]) # 09:00, 10:00, 17:00 UTC
>>> session_mask(ts, open="09:30", close="16:00")
array([False, True, False])
"""
in_session, _ = _session_components(ts, open, close, utc_offset, weekdays_only)
return in_session
[docs]
def session_id(
ts: NDArray[np.int64] | NDArray[np.float64],
open: str = "09:30",
close: str = "16:00",
utc_offset: float = 0.0,
weekdays_only: bool = True,
) -> NDArray[np.int64]:
""" Label each bar with a running trading-session id.
Bars outside a session get ``-1``. Bars inside a session get a
0-indexed, strictly increasing id shared by every bar of that session
(including across a data gap, e.g. a missing lunch bar): the id
increments whenever the session's opening day (see
:func:`_session_components`) changes from the previous in-session bar --
not merely when an array index transition occurs -- so it is robust to
``ts`` that only contains in-session samples (no off-hours rows to mark
a boundary with ``False``).
Parameters
----------
ts : ndarray of int64 or float64
Non-decreasing epoch-second (UTC) timestamps, shape ``(n,)``.
open, close, utc_offset, weekdays_only
See :func:`session_mask`.
Returns
-------
ndarray of int64
Shape ``(n,)``; ``-1`` outside sessions, else a 0-indexed running
session id.
Raises
------
ValueError
Same as :func:`session_mask`.
Examples
--------
>>> import numpy as np
>>> from fynance.data.sessions import session_id
>>> ts = np.array([9 * 3600, 10 * 3600, 17 * 3600, 33 * 3600, 34 * 3600])
>>> session_id(ts, open="09:30", close="16:00")
array([-1, 0, -1, -1, 1])
"""
in_session, open_day = _session_components(ts, open, close, utc_offset, weekdays_only)
ids = np.full(in_session.shape, -1, dtype=np.int64)
if not np.any(in_session):
return ids
session_days = open_day[in_session]
new_session = np.empty(session_days.shape, dtype=bool)
new_session[0] = True
new_session[1:] = session_days[1:] != session_days[:-1]
ids[in_session] = np.cumsum(new_session) - 1
return ids
[docs]
def session_bounds(
ts: NDArray[np.int64] | NDArray[np.float64],
open: str = "09:30",
close: str = "16:00",
utc_offset: float = 0.0,
weekdays_only: bool = True,
) -> NDArray[np.int64]:
""" First/last bar index of each trading session.
Parameters
----------
ts : ndarray of int64 or float64
Non-decreasing epoch-second (UTC) timestamps, shape ``(n,)``.
open, close, utc_offset, weekdays_only
See :func:`session_mask`.
Returns
-------
ndarray of int64
Shape ``(n_sessions, 2)``; row ``k`` is
``(first_index, last_index)`` (both inclusive, into ``ts``) of
session ``k``. Empty (``shape (0, 2)``) if no bar falls in a
session.
Raises
------
ValueError
Same as :func:`session_mask`.
Examples
--------
>>> import numpy as np
>>> from fynance.data.sessions import session_bounds
>>> ts = np.array([9 * 3600, 10 * 3600, 17 * 3600, 33 * 3600, 34 * 3600])
>>> session_bounds(ts, open="09:30", close="16:00")
array([[1, 1],
[4, 4]])
"""
ids = session_id(ts, open=open, close=close, utc_offset=utc_offset, weekdays_only=weekdays_only)
in_session = ids >= 0
if not np.any(in_session):
return np.empty((0, 2), dtype=np.int64)
index = np.nonzero(in_session)[0]
valid_ids = ids[in_session]
n_sessions = int(valid_ids[-1]) + 1
boundaries = np.arange(n_sessions)
first = index[np.searchsorted(valid_ids, boundaries, side="left")]
last = index[np.searchsorted(valid_ids, boundaries, side="right") - 1]
return np.stack([first, last], axis=1)
[docs]
def split_sessions(
X: NDArray,
ts: NDArray[np.int64] | NDArray[np.float64],
open: str = "09:30",
close: str = "16:00",
utc_offset: float = 0.0,
weekdays_only: bool = True,
) -> list[NDArray]:
""" Split ``X`` into one chunk per trading session.
Parameters
----------
X : ndarray
Array aligned with ``ts`` along its first axis.
ts : ndarray of int64 or float64
Non-decreasing epoch-second (UTC) timestamps, shape ``(n,)``.
open, close, utc_offset, weekdays_only
See :func:`session_mask`.
Returns
-------
list of ndarray
One slice of ``X`` per session, in session order. Bars outside every
session (``session_id == -1``) are skipped, so concatenating the
result along axis 0 reproduces ``X[session_mask(ts, ...)]``.
Raises
------
ValueError
Same as :func:`session_mask`.
Examples
--------
>>> import numpy as np
>>> from fynance.data.sessions import split_sessions
>>> ts = np.array([9 * 3600, 10 * 3600, 17 * 3600, 33 * 3600, 34 * 3600])
>>> X = np.arange(5)
>>> split_sessions(X, ts, open="09:30", close="16:00")
[array([1]), array([4])]
"""
X = np.asarray(X)
ids = session_id(ts, open=open, close=close, utc_offset=utc_offset, weekdays_only=weekdays_only)
in_session = ids >= 0
if not np.any(in_session):
return []
index = np.nonzero(in_session)[0]
valid_ids = ids[in_session]
n_sessions = int(valid_ids[-1]) + 1
split_points = np.searchsorted(valid_ids, np.arange(1, n_sessions))
groups = np.split(index, split_points)
return [X[g] for g in groups]