Skip to content

API index

Auto-generated index of the public surface of tsecon. Members are listed in the order they appear in tsecon/__init__.py.

TimeSeriesEconPy: a time-series language for macroeconomics.

Ported from TimeSeriesEcon.jl_ (Bank of Canada).

.. _TimeSeriesEcon.jl: https://github.com/bankofcanada/TimeSeriesEcon.jl

MIRRORS_JULIA_SHA module-attribute

MIRRORS_JULIA_SHA: Final[str] = (
    "fc0a0d01aed4903ea6c64b12d78d0bdf68468df6"
)

Commit SHA of bankofcanada/TimeSeriesEcon.jl mirrored by this release.

CompareDifference dataclass

One line of the recursive diff produced by :func:compare.

Attributes:

Name Type Description
path tuple[str, ...]

Hierarchical tuple of names, e.g. ('_', 'y', '2020Q3').

message str

Short human-readable description: 'different', 'missing in <name>', or 'same'.

Source code in src/tsecon/_various.py
@dataclass(frozen=True)
class CompareDifference:
    """One line of the recursive diff produced by :func:`compare`.

    Attributes
    ----------
    path
        Hierarchical tuple of names, e.g. ``('_', 'y', '2020Q3')``.
    message
        Short human-readable description: ``'different'``,
        ``'missing in <name>'``, or ``'same'``.
    """

    path: tuple[str, ...]
    message: str

    def __str__(self) -> str:
        return f"{'.'.join(self.path)}: {self.message}"

CompareResult dataclass

The structured return value of :func:compare.

Truthy iff the two inputs compared as equal — use if compare(a, b): ... for the Julia-style one-line check. str(result) reproduces the printed diff (one line per :class:CompareDifference). .differences exposes the diff programmatically.

Source code in src/tsecon/_various.py
@dataclass
class CompareResult:
    """The structured return value of :func:`compare`.

    Truthy iff the two inputs compared as equal — use
    ``if compare(a, b): ...`` for the Julia-style one-line check.
    ``str(result)`` reproduces the printed diff (one line per
    :class:`CompareDifference`). ``.differences`` exposes the diff
    programmatically.
    """

    equal: bool
    differences: list[CompareDifference] = field(default_factory=list)

    def __bool__(self) -> bool:
        return self.equal

    def __str__(self) -> str:
        if not self.differences:
            return "(no differences)"
        return "\n".join(str(d) for d in self.differences)

    def __repr__(self) -> str:
        n = len(self.differences)
        suffix = "" if n == 1 else "s"
        return f"CompareResult(equal={self.equal}, {n} difference{suffix})"

BDaily dataclass

Bases: CalendarFrequency

Business-daily frequency (Monday-Friday, no weekends).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class BDaily(CalendarFrequency):
    """Business-daily frequency (Monday-Friday, no weekends)."""

    _instance: ClassVar[BDaily | None] = None

    def __new__(cls) -> BDaily:
        if cls._instance is None:
            cls._instance = object.__new__(cls)
        return cls._instance

    def __repr__(self) -> str:
        return "BDaily()"

CalendarFrequency dataclass

Bases: Frequency

Abstract supertype for calendar-aware frequencies.

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class CalendarFrequency(Frequency):
    """Abstract supertype for calendar-aware frequencies."""

Daily dataclass

Bases: CalendarFrequency

Daily frequency (every calendar day).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class Daily(CalendarFrequency):
    """Daily frequency (every calendar day)."""

    _instance: ClassVar[Daily | None] = None

    def __new__(cls) -> Daily:
        if cls._instance is None:
            cls._instance = object.__new__(cls)
        return cls._instance

    def __repr__(self) -> str:
        return "Daily()"

Frequency dataclass

Abstract supertype for all frequencies.

Concrete subclasses use cached singletons via __new__; do not instantiate :class:Frequency directly.

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class Frequency:
    """Abstract supertype for all frequencies.

    Concrete subclasses use cached singletons via ``__new__``; do not
    instantiate :class:`Frequency` directly.
    """

HalfYearly dataclass

Bases: YPFrequency

Half-yearly frequency: 2 periods per year. Default end-month is June (6).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class HalfYearly(YPFrequency):
    """Half-yearly frequency: 2 periods per year. Default end-month is June (6)."""

    end_month: int = 6

    periods_per_year: ClassVar[int] = 2
    _cache: ClassVar[dict[int, HalfYearly]] = {}

    def __new__(cls, end_month: int = 6) -> HalfYearly:
        _validate_end_month("HalfYearly", end_month, 1, 6)
        cached = cls._cache.get(end_month)
        if cached is not None:
            return cached
        obj = object.__new__(cls)
        cls._cache[end_month] = obj
        return obj

    def __repr__(self) -> str:
        return "HalfYearly()" if self.end_month == 6 else f"HalfYearly(end_month={self.end_month})"

Monthly dataclass

Bases: YPFrequency

Monthly frequency: 12 periods per year.

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class Monthly(YPFrequency):
    """Monthly frequency: 12 periods per year."""

    periods_per_year: ClassVar[int] = 12
    _instance: ClassVar[Monthly | None] = None

    def __new__(cls) -> Monthly:
        if cls._instance is None:
            cls._instance = object.__new__(cls)
        return cls._instance

    def __repr__(self) -> str:
        return "Monthly()"

Quarterly dataclass

Bases: YPFrequency

Quarterly frequency: 4 periods per year. Default end-month is March (3).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class Quarterly(YPFrequency):
    """Quarterly frequency: 4 periods per year. Default end-month is March (3)."""

    end_month: int = 3

    periods_per_year: ClassVar[int] = 4
    _cache: ClassVar[dict[int, Quarterly]] = {}

    def __new__(cls, end_month: int = 3) -> Quarterly:
        _validate_end_month("Quarterly", end_month, 1, 3)
        cached = cls._cache.get(end_month)
        if cached is not None:
            return cached
        obj = object.__new__(cls)
        cls._cache[end_month] = obj
        return obj

    def __repr__(self) -> str:
        return "Quarterly()" if self.end_month == 3 else f"Quarterly(end_month={self.end_month})"

Unit dataclass

Bases: Frequency

Non-dimensional frequency (no calendar association).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class Unit(Frequency):
    """Non-dimensional frequency (no calendar association)."""

    _instance: ClassVar[Unit | None] = None

    def __new__(cls) -> Unit:
        if cls._instance is None:
            cls._instance = object.__new__(cls)
        return cls._instance

    def __repr__(self) -> str:
        return "Unit()"

Weekly dataclass

Bases: CalendarFrequency

Weekly frequency. end_day is the ISO weekday the week ends on (Mon=1..Sun=7).

Default is end_day=7 (Sunday).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class Weekly(CalendarFrequency):
    """Weekly frequency. ``end_day`` is the ISO weekday the week ends on (Mon=1..Sun=7).

    Default is ``end_day=7`` (Sunday).
    """

    end_day: int = 7

    _cache: ClassVar[dict[int, Weekly]] = {}

    def __new__(cls, end_day: int = 7) -> Weekly:
        if not isinstance(end_day, int) or isinstance(end_day, bool):
            msg = f"The end_day for a Weekly frequency must be an integer. Received: {end_day!r}"
            raise TypeError(msg)
        if not 1 <= end_day <= 7:
            msg = f"The end_day for a Weekly frequency must be between 1 and 7. Received: {end_day}"
            raise ValueError(msg)
        cached = cls._cache.get(end_day)
        if cached is not None:
            return cached
        obj = object.__new__(cls)
        cls._cache[end_day] = obj
        return obj

    def __repr__(self) -> str:
        return "Weekly()" if self.end_day == 7 else f"Weekly(end_day={self.end_day})"

Yearly dataclass

Bases: YPFrequency

Yearly frequency: 1 period per year. Default end-month is December (12).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class Yearly(YPFrequency):
    """Yearly frequency: 1 period per year. Default end-month is December (12)."""

    end_month: int = 12

    periods_per_year: ClassVar[int] = 1
    _cache: ClassVar[dict[int, Yearly]] = {}

    def __new__(cls, end_month: int = 12) -> Yearly:
        _validate_end_month("Yearly", end_month, 1, 12)
        cached = cls._cache.get(end_month)
        if cached is not None:
            return cached
        obj = object.__new__(cls)
        cls._cache[end_month] = obj
        return obj

    def __repr__(self) -> str:
        return "Yearly()" if self.end_month == 12 else f"Yearly(end_month={self.end_month})"

YPFrequency dataclass

Bases: CalendarFrequency

Abstract supertype for fixed-periods-per-year calendar frequencies.

Subclasses provide a class-level :attr:periods_per_year integer N. An MIT{F} value for F <: YPFrequency decomposes into a (year, period) pair via int(mit) = N * year + (period - 1).

Source code in src/tsecon/frequencies.py
@dataclass(frozen=True, slots=True)
class YPFrequency(CalendarFrequency):
    """Abstract supertype for fixed-periods-per-year calendar frequencies.

    Subclasses provide a class-level :attr:`periods_per_year` integer N. An
    ``MIT{F}`` value for ``F <: YPFrequency`` decomposes into a (year, period)
    pair via ``int(mit) = N * year + (period - 1)``.
    """

    periods_per_year: ClassVar[int]

MIT dataclass

A frequency-tagged moment in time.

Construct via :meth:from_yp for year/period frequencies, or via the convenience constructors :func:qq, :func:mm, :func:yy, :func:daily, :func:bdaily, :func:weekly. Direct MIT(frequency, value) construction takes a raw integer offset.

Source code in src/tsecon/mit.py
@dataclass(frozen=True, slots=True)
class MIT:
    """A frequency-tagged moment in time.

    Construct via :meth:`from_yp` for year/period frequencies, or via the
    convenience constructors :func:`qq`, :func:`mm`, :func:`yy`, :func:`daily`,
    :func:`bdaily`, :func:`weekly`. Direct ``MIT(frequency, value)``
    construction takes a raw integer offset.
    """

    frequency: Frequency
    value: int

    def __post_init__(self) -> None:
        if not isinstance(self.frequency, Frequency):
            msg = f"frequency must be a Frequency instance, got {type(self.frequency).__name__}"  # type: ignore[unreachable]
            raise TypeError(msg)
        if not isinstance(self.value, int) or isinstance(self.value, bool):
            msg = f"value must be int, got {type(self.value).__name__}"
            raise TypeError(msg)

    # -- alternate constructors --------------------------------------------

    @classmethod
    def from_yp(cls, frequency: YPFrequency, year_: int, period_: int) -> MIT:
        """Construct an MIT from year+period (only for YP frequencies)."""
        if not isinstance(frequency, YPFrequency):
            msg = f"from_yp requires a YPFrequency, got {type(frequency).__name__}"  # type: ignore[unreachable]
            raise TypeError(msg)
        n = frequency.periods_per_year
        if not 1 <= period_ <= n:
            msg = f"period must be 1..{n} for {type(frequency).__name__}, got {period_}"
            raise ValueError(msg)
        return cls(frequency, n * int(year_) + int(period_) - 1)

    # -- conversion / introspection ----------------------------------------

    def __int__(self) -> int:
        return self.value

    def __index__(self) -> int:
        return self.value

    def __float__(self) -> float:
        f = self.frequency
        if isinstance(f, YPFrequency):
            y, p = mit2yp(self)
            return y + (p - 1) / f.periods_per_year
        return float(self.value)

    # -- equality / hash ---------------------------------------------------

    def __eq__(self, other: object) -> bool:
        if isinstance(other, MIT):
            return self.frequency == other.frequency and self.value == other.value
        return NotImplemented

    def __hash__(self) -> int:
        return hash(("MIT", self.frequency, self.value))

    # -- arithmetic --------------------------------------------------------

    def __add__(self, other: object) -> MIT | float:
        if isinstance(other, MIT):
            raise TypeError("Illegal addition of two MIT values.")
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return MIT(self.frequency, self.value + other.value)
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return MIT(self.frequency, self.value + other)
        if isinstance(other, float):
            return float(self) + other
        return NotImplemented

    def __radd__(self, other: object) -> MIT | float:
        return self.__add__(other)

    def __sub__(self, other: object) -> MIT | Duration | float:
        if isinstance(other, MIT):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return Duration(self.frequency, self.value - other.value)
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return MIT(self.frequency, self.value - other.value)
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return MIT(self.frequency, self.value - other)
        if isinstance(other, float):
            return float(self) - other
        return NotImplemented

    # int - MIT is illegal (matches Julia)
    def __rsub__(self, other: object) -> Duration | float:
        if isinstance(other, float):
            return other - float(self)
        if isinstance(other, (int, bool)):
            raise TypeError("Cannot subtract MIT from int; use MIT - MIT instead.")
        return NotImplemented

    # -- ordering ----------------------------------------------------------

    def __lt__(self, other: object) -> bool:
        if isinstance(other, MIT):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value < other.value
        if isinstance(other, Duration):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        return NotImplemented

    def __le__(self, other: object) -> bool:
        if isinstance(other, MIT):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value <= other.value
        if isinstance(other, Duration):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        return NotImplemented

    def __gt__(self, other: object) -> bool:
        if isinstance(other, MIT):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value > other.value
        if isinstance(other, Duration):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        return NotImplemented

    def __ge__(self, other: object) -> bool:
        if isinstance(other, MIT):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value >= other.value
        if isinstance(other, Duration):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        return NotImplemented

    # -- range syntax ------------------------------------------------------

    def to(self, stop: MIT) -> MITRange:
        """Return the ``MITRange`` from ``self`` through ``stop`` (inclusive).

        Mirrors Julia's ``start:stop`` syntax for MITs. Python's slice/colon
        cannot be overloaded outside of indexing, so this method is the
        idiomatic spelling. The free function :func:`tsecon.mitrange.mitrange`
        also works.
        """
        from tsecon.mitrange import MITRange  # noqa: PLC0415  (avoid circular import)

        return MITRange(self, stop)

    # -- repr / str --------------------------------------------------------

    def __repr__(self) -> str:
        return _format_mit(self)

    def __str__(self) -> str:
        return _format_mit(self)

from_yp classmethod

from_yp(
    frequency: YPFrequency, year_: int, period_: int
) -> MIT

Construct an MIT from year+period (only for YP frequencies).

Source code in src/tsecon/mit.py
@classmethod
def from_yp(cls, frequency: YPFrequency, year_: int, period_: int) -> MIT:
    """Construct an MIT from year+period (only for YP frequencies)."""
    if not isinstance(frequency, YPFrequency):
        msg = f"from_yp requires a YPFrequency, got {type(frequency).__name__}"  # type: ignore[unreachable]
        raise TypeError(msg)
    n = frequency.periods_per_year
    if not 1 <= period_ <= n:
        msg = f"period must be 1..{n} for {type(frequency).__name__}, got {period_}"
        raise ValueError(msg)
    return cls(frequency, n * int(year_) + int(period_) - 1)

to

to(stop: MIT) -> MITRange

Return the MITRange from self through stop (inclusive).

Mirrors Julia's start:stop syntax for MITs. Python's slice/colon cannot be overloaded outside of indexing, so this method is the idiomatic spelling. The free function :func:tsecon.mitrange.mitrange also works.

Source code in src/tsecon/mit.py
def to(self, stop: MIT) -> MITRange:
    """Return the ``MITRange`` from ``self`` through ``stop`` (inclusive).

    Mirrors Julia's ``start:stop`` syntax for MITs. Python's slice/colon
    cannot be overloaded outside of indexing, so this method is the
    idiomatic spelling. The free function :func:`tsecon.mitrange.mitrange`
    also works.
    """
    from tsecon.mitrange import MITRange  # noqa: PLC0415  (avoid circular import)

    return MITRange(self, stop)

Duration dataclass

A frequency-tagged distance between two MITs.

Source code in src/tsecon/mit.py
@dataclass(frozen=True, slots=True)
class Duration:
    """A frequency-tagged distance between two MITs."""

    frequency: Frequency
    value: int

    def __post_init__(self) -> None:
        if not isinstance(self.frequency, Frequency):
            msg = f"frequency must be a Frequency instance, got {type(self.frequency).__name__}"  # type: ignore[unreachable]
            raise TypeError(msg)
        if not isinstance(self.value, int) or isinstance(self.value, bool):
            msg = f"value must be int, got {type(self.value).__name__}"
            raise TypeError(msg)

    # -- conversion --------------------------------------------------------

    def __int__(self) -> int:
        return self.value

    def __index__(self) -> int:
        return self.value

    def __float__(self) -> float:
        f = self.frequency
        if isinstance(f, YPFrequency):
            return self.value / f.periods_per_year
        return float(self.value)

    def __bool__(self) -> bool:
        return self.value != 0

    # -- equality ----------------------------------------------------------

    def __eq__(self, other: object) -> bool:
        if isinstance(other, Duration):
            return self.frequency == other.frequency and self.value == other.value
        return NotImplemented

    def __hash__(self) -> int:
        return hash(("Duration", self.frequency, self.value))

    # -- unary -------------------------------------------------------------

    def __neg__(self) -> Duration:
        return Duration(self.frequency, -self.value)

    def __pos__(self) -> Duration:
        return self

    def __abs__(self) -> Duration:
        return Duration(self.frequency, abs(self.value))

    # -- arithmetic --------------------------------------------------------

    def __add__(self, other: object) -> Duration:
        if isinstance(other, MIT):
            raise TypeError("Illegal addition of Duration and MIT. Try MIT + Duration.")
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return Duration(self.frequency, self.value + other.value)
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return Duration(self.frequency, self.value + other)
        return NotImplemented

    def __radd__(self, other: object) -> Duration:
        return self.__add__(other)

    def __sub__(self, other: object) -> Duration:
        if isinstance(other, MIT):
            raise TypeError("Cannot subtract MIT from Duration.")
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return Duration(self.frequency, self.value - other.value)
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return Duration(self.frequency, self.value - other)
        return NotImplemented

    def __rsub__(self, other: object) -> Duration:
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return Duration(self.frequency, other - self.value)
        return NotImplemented

    def __mul__(self, other: object) -> Duration:
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return Duration(self.frequency, self.value * other)
        return NotImplemented

    def __rmul__(self, other: object) -> Duration:
        return self.__mul__(other)

    def __floordiv__(self, other: object) -> Duration:
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return Duration(self.frequency, self.value // other.value)
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return Duration(self.frequency, self.value // other)
        return NotImplemented

    def __mod__(self, other: object) -> Duration:
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return Duration(self.frequency, self.value % other.value)
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return Duration(self.frequency, self.value % other)
        return NotImplemented

    # -- comparison --------------------------------------------------------

    def __lt__(self, other: object) -> bool:
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value < other.value
        if isinstance(other, MIT):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return self.value < other
        return NotImplemented

    def __le__(self, other: object) -> bool:
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value <= other.value
        if isinstance(other, MIT):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return self.value <= other
        return NotImplemented

    def __gt__(self, other: object) -> bool:
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value > other.value
        if isinstance(other, MIT):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return self.value > other
        return NotImplemented

    def __ge__(self, other: object) -> bool:
        if isinstance(other, Duration):
            if self.frequency != other.frequency:
                raise _mixed_freq_error(self, other)
            return self.value >= other.value
        if isinstance(other, MIT):
            raise TypeError(
                f"Illegal comparison of {type(self).__name__} and {type(other).__name__}."
            )
        if isinstance(other, bool):
            return NotImplemented
        if isinstance(other, int):
            return self.value >= other
        return NotImplemented

    # -- repr / str --------------------------------------------------------

    def __repr__(self) -> str:
        return str(self.value)

    def __str__(self) -> str:
        return str(self.value)

MITRange dataclass

An inclusive range of MITs of a single frequency.

Construct with MITRange(start, stop) for unit steps, or MITRange(start, stop, step) for a step range. step may be a nonzero int (interpreted in the range's frequency) or a :class:Duration of the same frequency. Negative step walks backward (MITRange(10U, 1U, -1) mirrors Julia's 10U:-1:1U); ranges whose endpoints have the opposite sense to step are empty.

Source code in src/tsecon/mitrange.py
@dataclass(frozen=True, slots=True)
class MITRange:
    """An inclusive range of MITs of a single frequency.

    Construct with ``MITRange(start, stop)`` for unit steps, or
    ``MITRange(start, stop, step)`` for a step range. ``step`` may be a
    nonzero ``int`` (interpreted in the range's frequency) or a
    :class:`Duration` of the same frequency. Negative ``step`` walks
    backward (``MITRange(10U, 1U, -1)`` mirrors Julia's ``10U:-1:1U``);
    ranges whose endpoints have the opposite sense to ``step`` are empty.
    """

    start: MIT
    stop: MIT
    step: int = 1

    def __post_init__(self) -> None:
        if not isinstance(self.start, MIT) or not isinstance(self.stop, MIT):
            raise TypeError("MITRange endpoints must be MIT instances.")
        if self.start.frequency != self.stop.frequency:
            raise TypeError(
                f"Cannot construct MITRange across frequencies: "
                f"{prettyprint_frequency(self.start.frequency)} and "
                f"{prettyprint_frequency(self.stop.frequency)}."
            )
        if not isinstance(self.step, int) or isinstance(self.step, bool):
            raise TypeError(f"step must be int, got {type(self.step).__name__}")
        if self.step == 0:
            raise ValueError("step must be nonzero.")

    # -- introspection -----------------------------------------------------

    @property
    def frequency(self) -> Frequency:
        """The frequency shared by ``start`` and ``stop``."""
        return self.start.frequency

    def __len__(self) -> int:
        span = self.stop.value - self.start.value
        # Sign mismatch between span and step means the range is empty
        # (e.g. start=1, stop=10, step=-1, or start=10, stop=1, step=+1).
        if (span > 0 and self.step < 0) or (span < 0 and self.step > 0):
            return 0
        return span // self.step + 1

    def __bool__(self) -> bool:
        return len(self) > 0

    def is_empty(self) -> bool:
        """Return True if the range contains no MITs."""
        return len(self) == 0

    def first(self) -> MIT:
        """Return the first MIT in the range. Raises if empty."""
        if not self:
            raise IndexError("MITRange is empty.")
        return self.start

    def last(self) -> MIT:
        """Return the last MIT in the range. Raises if empty.

        For a forward range (``step > 0``) this is the largest MIT not past
        ``stop``; for a reversed range (``step < 0``) it is the smallest
        MIT not past ``stop``. In both cases, ``last() == start + (len-1) * step``.
        """
        if not self:
            raise IndexError("MITRange is empty.")
        n = len(self) - 1
        return MIT(self.frequency, self.start.value + n * self.step)

    # -- iteration ---------------------------------------------------------

    def __iter__(self) -> Iterator[MIT]:
        for i in range(len(self)):
            yield MIT(self.frequency, self.start.value + i * self.step)

    def __contains__(self, item: object) -> bool:
        if not isinstance(item, MIT):
            return False
        if item.frequency != self.frequency:
            return False
        if self.is_empty():
            return False
        start_val = self.start.value
        last_val = self.last().value
        lo = min(start_val, last_val)
        hi = max(start_val, last_val)
        if item.value < lo or item.value > hi:
            return False
        # Same modulo check works for both signs because Python's `%` returns
        # a value with the divisor's sign (e.g. -4 % -2 == 0).
        return (item.value - start_val) % self.step == 0

    # -- indexing ----------------------------------------------------------

    @overload
    def __getitem__(self, index: int) -> MIT: ...
    @overload
    def __getitem__(self, index: slice) -> MITRange: ...
    def __getitem__(self, index: int | slice) -> MIT | MITRange:
        if isinstance(index, slice):
            length = len(self)
            # Delegate index arithmetic to Python's built-in range; works for
            # both ascending and descending slice steps.
            sub = range(length)[index]
            if len(sub) == 0:
                # Pick a canonical empty representation (step=1, start..start-1)
                # so the sign of self.step doesn't accidentally produce a non-
                # empty result via step composition.
                empty_start = self.start
                empty_stop = MIT(self.frequency, self.start.value - 1)
                return MITRange(empty_start, empty_stop, 1)
            new_start = MIT(self.frequency, self.start.value + sub[0] * self.step)
            new_stop = MIT(self.frequency, self.start.value + sub[-1] * self.step)
            return MITRange(new_start, new_stop, self.step * sub.step)
        if isinstance(index, bool):
            raise TypeError("MITRange indices must be int or slice, not bool.")
        if not isinstance(index, int):
            raise TypeError(f"MITRange indices must be int or slice, got {type(index).__name__}")
        n = len(self)
        if index < 0:
            index += n
        if not 0 <= index < n:
            raise IndexError(f"MITRange index {index} out of range (len={n}).")
        return MIT(self.frequency, self.start.value + index * self.step)

    # -- equality / hash ---------------------------------------------------

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, MITRange):
            return NotImplemented
        if self.is_empty() and other.is_empty():
            return self.frequency == other.frequency
        return self.start == other.start and self.last() == other.last() and self.step == other.step

    def __hash__(self) -> int:
        if self.is_empty():
            return hash(("MITRange-empty", self.frequency))
        return hash(("MITRange", self.start, self.last(), self.step))

    # -- repr / str --------------------------------------------------------

    def __repr__(self) -> str:
        if self.step == 1:
            return f"{self.start}:{self.stop}"
        return f"{self.start}:{self.step}:{self.stop}"

    def __str__(self) -> str:
        return repr(self)

frequency property

frequency: Frequency

The frequency shared by start and stop.

is_empty

is_empty() -> bool

Return True if the range contains no MITs.

Source code in src/tsecon/mitrange.py
def is_empty(self) -> bool:
    """Return True if the range contains no MITs."""
    return len(self) == 0

first

first() -> MIT

Return the first MIT in the range. Raises if empty.

Source code in src/tsecon/mitrange.py
def first(self) -> MIT:
    """Return the first MIT in the range. Raises if empty."""
    if not self:
        raise IndexError("MITRange is empty.")
    return self.start

last

last() -> MIT

Return the last MIT in the range. Raises if empty.

For a forward range (step > 0) this is the largest MIT not past stop; for a reversed range (step < 0) it is the smallest MIT not past stop. In both cases, last() == start + (len-1) * step.

Source code in src/tsecon/mitrange.py
def last(self) -> MIT:
    """Return the last MIT in the range. Raises if empty.

    For a forward range (``step > 0``) this is the largest MIT not past
    ``stop``; for a reversed range (``step < 0``) it is the smallest
    MIT not past ``stop``. In both cases, ``last() == start + (len-1) * step``.
    """
    if not self:
        raise IndexError("MITRange is empty.")
    n = len(self) - 1
    return MIT(self.frequency, self.start.value + n * self.step)

MVTSeries

A 2-D NumPy array paired with a frequency-tagged firstdate and named columns.

See the module docstring for the storage model and the wrap-vs-copy contract on the constructor.

Source code in src/tsecon/mvtseries.py
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
class MVTSeries:
    """A 2-D NumPy array paired with a frequency-tagged firstdate and named columns.

    See the module docstring for the storage model and the wrap-vs-copy
    contract on the constructor.
    """

    __slots__ = ("_columns", "_firstdate", "_values")

    __array_priority__: ClassVar[float] = 1000.0

    _firstdate: MIT
    _values: np.ndarray
    _columns: dict[str, TSeries]

    # -- construction ------------------------------------------------------

    def __init__(
        self,
        firstdate_or_range: MIT | MITRange | None = None,
        names: _NamesLike | None = None,
        values: _ArrayLike | TSeries | Callable[..., Any] | float | int | bool | None = None,
        *,
        dtype: npt.DTypeLike | None = None,
        copy: bool = False,
        **columns: Any,
    ) -> None:
        """Construct an MVTSeries.

        Parameters
        ----------
        firstdate_or_range
            An :class:`~tsecon.mit.MIT` (giving the first row date) or a
            :class:`~tsecon.mitrange.MITRange` (giving both first date and
            number of rows). If omitted and column kwargs are provided, the
            range is taken to be ``rangeof_span(*columns.values())``.
        names
            A column name (``str``), an iterable of names, or omitted to
            use the kwargs ``**columns`` to supply both names and values.
        values
            One of:

            * a 2-D ``ndarray`` (or 1-D for a single-column MVTSeries),
            * a scalar (fill),
            * a callable initializer ``init(nrows, ncols) -> ndarray``
              such as :func:`numpy.zeros` / :func:`numpy.ones`,
            * a :class:`~tsecon.tseries.TSeries` (init each column from it),
            * ``None`` (uninitialized → NaN-filled / typenan-filled).
        dtype
            Optional dtype override. Defaults to ``float64`` for empty /
            scalar / function initializers; for an array input, the dtype
            of that array.
        copy
            ``True`` forces an independent allocation when ``values`` is an
            ndarray. Default ``False`` (wrap-by-default per decision 16).
        **columns
            Alternative spelling: ``MVTSeries(rng, a=..., b=...)`` builds
            an MVTSeries with one column per kwarg. The value can be a
            :class:`~tsecon.tseries.TSeries`, a 1-D array, or a scalar
            (filled). If ``firstdate_or_range`` is omitted, the range is
            ``rangeof_span`` of all kwarg values that have a range.

        Notes
        -----
        Wrap-by-default: passing an already-compatible 2-D ``ndarray`` as
        ``values`` aliases that buffer (matches xarray's ``DataArray``).
        Use ``copy=True`` for an independent allocation.
        """
        # -- kwargs-only form: MVTSeries(rng?, **columns)
        if columns and (values is None and names is None):
            self._init_from_kwargs(firstdate_or_range, columns, dtype=dtype)
            return

        # -- bare default: MVTSeries() == MVTSeries(1U, 0x0)
        if firstdate_or_range is None:
            msg = "MVTSeries() requires either a firstdate/range or column kwargs."
            raise TypeError(msg)

        names_list: list[str] = [] if names is None else _names_as_list(names)
        ncols = len(names_list)

        # -- MITRange form
        if isinstance(firstdate_or_range, MITRange):
            rng = firstdate_or_range
            nrows = len(rng)

            target_dtype: np.dtype[Any]
            arr: np.ndarray

            if values is None:
                target_dtype = np.dtype(dtype) if dtype is not None else np.dtype(np.float64)
                arr = np.full((nrows, ncols), typenan(target_dtype), dtype=target_dtype)
            elif callable(values) and not isinstance(values, np.ndarray):
                # Initializer function. NumPy convention is one shape tuple
                # (``np.zeros((nrows, ncols))``); we also accept the two-
                # positional-arg form (``np.random.rand(nrows, ncols)``) by
                # trying the tuple call first and falling back on TypeError.
                init_fn = values
                try:
                    arr = np.asarray(init_fn((nrows, ncols)))
                except TypeError:
                    arr = np.asarray(init_fn(nrows, ncols))
                if dtype is not None:
                    arr = arr.astype(dtype, copy=False)
            elif _is_scalar_number(values):
                target_dtype = np.dtype(dtype) if dtype is not None else np.asarray(values).dtype
                arr = np.full((nrows, ncols), values, dtype=target_dtype)
            elif isinstance(values, TSeries):
                # Initialize all columns from the same TSeries (truncated/aligned).
                src = values
                if src.frequency != rng.frequency:
                    raise _mixed_freq_error(src.frequency, rng.frequency)
                target_dtype = np.dtype(dtype) if dtype is not None else np.dtype(src.values.dtype)
                arr = np.full((nrows, ncols), typenan(target_dtype), dtype=target_dtype)
                # Place src values where rng and src.range overlap.
                lo = max(rng.start.value, src.firstdate.value)
                hi = min(rng.stop.value, src.lastdate.value)
                if lo <= hi:
                    rng_off = lo - rng.start.value
                    src_off = lo - src.firstdate.value
                    n = hi - lo + 1
                    for j in range(ncols):
                        arr[rng_off : rng_off + n, j] = src.values[src_off : src_off + n]
            else:
                arr = _coerce_values_2d(values, dtype=dtype, copy=copy)
                if arr.shape != (nrows, ncols):
                    msg = (
                        "Number of periods and variables do not match size of data: "
                        f"({nrows}, {ncols}) != {arr.shape}."
                    )
                    raise ValueError(msg)
            self._firstdate = rng.start
            self._values = arr
            self._columns = _build_column_views(rng.start, names_list, arr)
            return

        # -- MIT form (firstdate only; nrows inferred from values or 0)
        if isinstance(firstdate_or_range, MIT):
            fd = firstdate_or_range
            if values is None:
                target_dtype = np.dtype(dtype) if dtype is not None else np.dtype(np.float64)
                arr = np.zeros((0, ncols), dtype=target_dtype)
            elif _is_scalar_number(values):
                msg = (
                    "MVTSeries(MIT, names, scalar) is ambiguous (unknown row count); "
                    "pass an MITRange to fill, or a 2-D array to size."
                )
                raise TypeError(msg)
            else:
                arr = _coerce_values_2d(values, dtype=dtype, copy=copy)
                if arr.shape[1] != ncols:
                    msg = f"Number of names and columns don't match: {ncols} != {arr.shape[1]}."
                    raise ValueError(msg)
            self._firstdate = fd
            self._values = arr
            self._columns = _build_column_views(fd, names_list, arr)
            return

        msg = (  # type: ignore[unreachable]
            f"MVTSeries first argument must be MIT, MITRange, or None; "
            f"got {type(firstdate_or_range).__name__}."
        )
        raise TypeError(msg)

    def _init_from_kwargs(
        self,
        firstdate_or_range: MIT | MITRange | None,
        columns: dict[str, Any],
        *,
        dtype: npt.DTypeLike | None,
    ) -> None:
        """Build from ``MVTSeries(rng?, name=value, ...)`` kwargs form."""
        # Figure out the range.
        rng: MITRange
        if isinstance(firstdate_or_range, MITRange):
            rng = firstdate_or_range
        elif isinstance(firstdate_or_range, MIT):
            # Compute end from the longest-tailed TSeries kwarg, else 0 rows.
            last_value = firstdate_or_range.value - 1
            for v in columns.values():
                if isinstance(v, TSeries):
                    last_value = max(last_value, v.lastdate.value)
                elif isinstance(v, np.ndarray) or (
                    isinstance(v, list) and not _is_scalar_number(v)
                ):
                    n = len(v)
                    last_value = max(last_value, firstdate_or_range.value + n - 1)
            stop = MIT(firstdate_or_range.frequency, last_value)
            rng = MITRange(firstdate_or_range, stop)
        else:
            # Span across all TSeries / MITRange kwargs.
            spans = [
                v.range if isinstance(v, TSeries) else v
                for v in columns.values()
                if isinstance(v, (TSeries, MITRange))
            ]
            if not spans:
                msg = (
                    "MVTSeries() with kwargs only requires at least one TSeries or MITRange "
                    "value to determine the range."
                )
                raise TypeError(msg)
            rng = rangeof_span(*spans)

        # Resolve element type.
        if dtype is not None:
            et: np.dtype[Any] = np.dtype(dtype)
        else:
            promoted: list[np.dtype[Any]] = [np.dtype(np.float64)]
            for v in columns.values():
                if isinstance(v, TSeries):
                    promoted.append(v.values.dtype)
                elif isinstance(v, np.ndarray):
                    promoted.append(v.dtype)
                elif isinstance(v, (bool, np.bool_)):
                    promoted.append(np.dtype(np.bool_))
                elif _is_scalar_number(v):
                    promoted.append(np.asarray(v).dtype)
            et = np.result_type(*promoted) if promoted else np.dtype(np.float64)

        nrows = len(rng)
        names_list = list(columns.keys())
        ncols = len(names_list)
        arr = np.full((nrows, ncols), typenan(et), dtype=et)
        self._firstdate = rng.start
        self._values = arr
        self._columns = _build_column_views(rng.start, names_list, arr)

        # Now copy each kwarg's data into its column.
        for j, (name, val) in enumerate(columns.items()):
            col_anchor = self._columns[name]
            if isinstance(val, TSeries):
                if val.frequency != rng.frequency:
                    raise _mixed_freq_error(val.frequency, rng.frequency)
                lo = max(rng.start.value, val.firstdate.value)
                hi = min(rng.stop.value, val.lastdate.value)
                if lo <= hi:
                    rng_off = lo - rng.start.value
                    src_off = lo - val.firstdate.value
                    n = hi - lo + 1
                    arr[rng_off : rng_off + n, j] = val.values[src_off : src_off + n]
            elif _is_scalar_number(val):
                arr[:, j] = val
            else:
                vec = np.asarray(val, dtype=et)
                if vec.ndim != 1 or vec.shape[0] != nrows:
                    msg = (
                        f"Column {name!r}: vector length {vec.shape[0]} does not match "
                        f"MVTSeries range length {nrows}."
                    )
                    raise ValueError(msg)
                arr[:, j] = vec
            # Re-anchor the column TSeries to the (re-)written column (the
            # buffer view is unchanged, but we keep the same TSeries instance).
            del col_anchor  # explicitly unused — we wrote through arr above

    # -- alternate constructors -------------------------------------------

    @classmethod
    def empty(
        cls,
        rng: MITRange,
        names: _NamesLike = (),
        *,
        dtype: npt.DTypeLike = np.float64,
    ) -> MVTSeries:
        """Construct an uninitialized MVTSeries (typenan-filled)."""
        return cls(rng, names, dtype=dtype)

    @classmethod
    def zeros(
        cls,
        rng: MITRange,
        names: _NamesLike,
        *,
        dtype: npt.DTypeLike = np.float64,
    ) -> MVTSeries:
        """Construct an MVTSeries of zeros."""
        return cls(rng, names, 0, dtype=dtype)

    @classmethod
    def ones(
        cls,
        rng: MITRange,
        names: _NamesLike,
        *,
        dtype: npt.DTypeLike = np.float64,
    ) -> MVTSeries:
        """Construct an MVTSeries of ones."""
        return cls(rng, names, 1, dtype=dtype)

    @classmethod
    def fill(
        cls,
        rng: MITRange,
        names: _NamesLike,
        value: float | int | bool,
        *,
        dtype: npt.DTypeLike | None = None,
    ) -> MVTSeries:
        """Construct an MVTSeries filled with ``value``."""
        return cls(rng, names, value, dtype=dtype)

    # -- accessors ---------------------------------------------------------

    @property
    def values(self) -> np.ndarray:
        """The underlying 2-D NumPy array. Mutating it mutates the MVTSeries."""
        return self._values

    @property
    def firstdate(self) -> MIT:
        """The MIT of the first stored row."""
        return self._firstdate

    @property
    def lastdate(self) -> MIT:
        """The MIT of the last stored row. Undefined when the MVTSeries is empty."""
        return MIT(self._firstdate.frequency, self._firstdate.value + self._values.shape[0] - 1)

    @property
    def frequency(self) -> Frequency:
        """The shared frequency of all rows."""
        return self._firstdate.frequency

    @property
    def range(self) -> MITRange:
        """The MITRange covering ``firstdate..lastdate``.

        For an empty MVTSeries (no rows) returns an empty MITRange.
        """
        if self._values.shape[0] == 0:
            empty_stop = MIT(self._firstdate.frequency, self._firstdate.value - 1)
            return MITRange(self._firstdate, empty_stop)
        return MITRange(self._firstdate, self.lastdate)

    @property
    def column_names(self) -> tuple[str, ...]:
        """The column names in insertion order."""
        return tuple(self._columns.keys())

    @property
    def columns(self) -> dict[str, TSeries]:
        """The column-name → TSeries-view mapping (live; do not mutate keys)."""
        return self._columns

    @property
    def dtype(self) -> np.dtype[Any]:
        """The dtype of the underlying matrix."""
        return self._values.dtype

    @property
    def shape(self) -> tuple[int, int]:
        """Shape of the underlying matrix ``(nrows, ncols)``."""
        return (int(self._values.shape[0]), int(self._values.shape[1]))

    @property
    def ndim(self) -> int:
        """Number of dimensions (always 2)."""
        return 2

    # -- size / iteration --------------------------------------------------

    def __len__(self) -> int:
        return int(self._values.shape[0])

    def __iter__(self) -> Iterator[np.ndarray]:
        # Row iteration, mirroring NumPy matrix iteration.
        return iter(self._values)

    def __contains__(self, key: object) -> bool:
        # Membership = column-name membership (matches Julia ``haskey``).
        return isinstance(key, str) and key in self._columns

    def keys(self) -> tuple[str, ...]:
        """Return the column names (insertion order)."""
        return tuple(self._columns.keys())

    def is_empty(self) -> bool:
        """Return True iff the MVTSeries has no rows or no columns."""
        return bool(self._values.shape[0] == 0 or self._values.shape[1] == 0)

    # -- copy / similar ----------------------------------------------------

    def copy(self, *, deep: bool = False) -> MVTSeries:
        """Return an independent copy with its own matrix buffer.

        ``deep`` is accepted for API uniformity with
        :meth:`~tsecon.tseries.TSeries.copy` and
        :meth:`~tsecon.workspace.Workspace.copy`; for MVTSeries it is a
        semantic no-op because the only mutable referents below the
        wrapper are the matrix buffer (always copied) and the per-column
        TSeries views (which are rebuilt to point at the fresh buffer).
        """
        del deep  # accepted for uniformity; see docstring
        return MVTSeries(
            self._firstdate,
            list(self._columns.keys()),
            self._values.copy(),
        )

    def __copy__(self) -> MVTSeries:
        return self.copy()

    def __deepcopy__(self, memo: dict[int, Any]) -> MVTSeries:
        new = self.copy()
        memo[id(self)] = new
        return new

    def similar(
        self,
        *,
        dtype: npt.DTypeLike | None = None,
        rng: MITRange | None = None,
        names: _NamesLike | None = None,
    ) -> MVTSeries:
        """Return an uninitialized MVTSeries with matching shape (or overrides)."""
        out_rng = rng if rng is not None else self.range
        out_names = list(self._columns.keys()) if names is None else _names_as_list(names)
        out_dtype = dtype if dtype is not None else self._values.dtype
        return MVTSeries.empty(out_rng, out_names, dtype=out_dtype)

    # -- frequency / range helpers -----------------------------------------

    def _check_freq_for(self, other_freq: Frequency, *, op: str) -> None:
        if self._firstdate.frequency != other_freq:
            msg = (
                f"Mixing frequencies not allowed in {op}: "
                f"{prettyprint_frequency(self.frequency)} and "
                f"{prettyprint_frequency(other_freq)}."
            )
            raise TypeError(msg)

    def _row_index(self, m: MIT) -> int:
        return int(m.value - self._firstdate.value)

    def _col_index(self, name: str) -> int:
        try:
            return list(self._columns.keys()).index(name)
        except ValueError as e:
            msg = f"MVTSeries has no column {name!r}."
            raise KeyError(msg) from e

    # -- dot access --------------------------------------------------------

    def __getattr__(self, name: str) -> Any:
        # Only invoked when normal attribute lookup fails. Look up ``name``
        # in the column dict — if found, return the TSeries anchor (which
        # the user can mutate to write back into the parent matrix).
        try:
            return object.__getattribute__(self, "_columns")[name]
        except (AttributeError, KeyError) as e:
            msg = f"MVTSeries has no column {name!r}."
            raise AttributeError(msg) from e

    def __setattr__(self, name: str, value: Any) -> None:
        if name in MVTSeries.__slots__:
            object.__setattr__(self, name, value)
            return
        # Reroute to column setitem (matches Julia ``setproperty!``).
        if "_columns" in object.__dir__(self) and name in self._columns:
            self._set_column(name, value)
            return
        msg = (
            f"Cannot create new column {name!r} via attribute assignment. "
            f"Build a new MVTSeries with the additional column."
        )
        raise AttributeError(msg)

    def __dir__(self) -> list[str]:
        return [*object.__dir__(self), *self._columns.keys()]

    # -- indexing: getitem -------------------------------------------------

    def __getitem__(self, key: Any) -> Any:
        if isinstance(key, tuple) and len(key) == 2 and not _is_name_tuple(key):
            return self._getitem_two(key[0], key[1])
        return self._getitem_one(key)

    def _getitem_one(self, key: Any) -> Any:
        # String → column TSeries.
        if isinstance(key, str):
            try:
                return self._columns[key]
            except KeyError as e:
                msg = f"MVTSeries has no column {key!r}."
                raise KeyError(msg) from e

        # MIT → row vector (1-D ndarray).
        if isinstance(key, MIT):
            self._check_freq_for(key.frequency, op="indexing")
            i = self._row_index(key)
            if not 0 <= i < self._values.shape[0]:
                msg = f"MIT {key!s} is outside the stored range {self.range!s}."
                raise IndexError(msg)
            return self._values[i, :]

        # MITRange → MVTSeries (rows, same columns).
        if isinstance(key, MITRange):
            self._check_freq_for(key.frequency, op="indexing")
            i0 = self._row_index(key.start)
            i1 = self._row_index(key.last())
            if i0 < 0 or i1 >= self._values.shape[0]:
                msg = f"MITRange {key!s} is not contained in stored range {self.range!s}."
                raise IndexError(msg)
            sub = self._values[i0 : i1 + 1, :].copy()
            return MVTSeries(key.start, list(self._columns.keys()), sub)

        # Tuple/list of column names → subset MVTSeries (copy).
        if isinstance(key, list) or (isinstance(key, tuple) and self._is_name_collection(key)):
            names = [str(n) for n in key]
            self._check_columns_exist(names)
            inds = [self._col_index(n) for n in names]
            sub = self._values[:, inds].copy()
            return MVTSeries(self._firstdate, names, sub)

        # Bool array on rows (1-D length nrows): return submatrix.
        if isinstance(key, np.ndarray) and key.dtype == np.bool_:
            if key.ndim == 1 and key.shape[0] == self._values.shape[0]:
                return self._values[key, :]
            # 2-D boolean: treat as flat NumPy fancy indexing.
            return self._values[key]

        # Bool list: convert and recurse.
        if isinstance(key, list) and all(isinstance(b, (bool, np.bool_)) for b in key):
            return self._getitem_one(np.asarray(key, dtype=bool))

        # Slice without MIT endpoints → fall through to ndarray.
        if isinstance(key, slice):
            if isinstance(key.start, MIT) or isinstance(key.stop, MIT):
                return self._slice_with_mit(key)
            return self._values[key]

        # Integer or integer-array → fall through to ndarray.
        if _is_int_like(key):
            return self._values[int(key)]
        if isinstance(key, np.ndarray) and np.issubdtype(key.dtype, np.integer):
            return self._values[key]

        msg = f"MVTSeries does not support indexing with {type(key).__name__}."
        raise TypeError(msg)

    def _getitem_two(self, r: Any, c: Any) -> Any:
        # Colon shortcuts.
        if isinstance(r, slice) and r == slice(None) and isinstance(c, slice) and c == slice(None):
            return self
        if isinstance(r, slice) and r == slice(None):
            return self._getitem_one(c)
        if isinstance(c, slice) and c == slice(None):
            return self._getitem_one(r)

        # Bool row-mask + (Symbol or list).
        if isinstance(r, np.ndarray) and r.dtype == np.bool_ and r.ndim == 1:
            if isinstance(c, str):
                col_idx = self._col_index(c)
                return self._values[r, col_idx]
            if isinstance(c, (list, tuple)):
                col_inds = np.asarray([self._col_index(str(n)) for n in c], dtype=np.intp)
                return self._values[np.ix_(r, col_inds)]
            if isinstance(c, np.ndarray) and c.dtype == np.bool_:
                return self._values[np.ix_(r, c)]

        # MIT + str → scalar.
        if isinstance(r, MIT) and isinstance(c, str):
            self._check_freq_for(r.frequency, op="indexing")
            i = self._row_index(r)
            j = self._col_index(c)
            if not 0 <= i < self._values.shape[0]:
                msg = f"MIT {r!s} is outside the stored range {self.range!s}."
                raise IndexError(msg)
            return self._values[i, j]

        # MIT + collection → row slice across multiple columns.
        if isinstance(r, MIT) and isinstance(c, (list, tuple)):
            self._check_freq_for(r.frequency, op="indexing")
            i = self._row_index(r)
            if not 0 <= i < self._values.shape[0]:
                msg = f"MIT {r!s} is outside the stored range {self.range!s}."
                raise IndexError(msg)
            row_inds = np.asarray([self._col_index(str(n)) for n in c], dtype=np.intp)
            return self._values[i, row_inds]

        # MITRange + str → TSeries (column over range).
        if isinstance(r, MITRange) and isinstance(c, str):
            self._check_freq_for(r.frequency, op="indexing")
            i0 = self._row_index(r.start)
            i1 = self._row_index(r.last())
            if i0 < 0 or i1 >= self._values.shape[0]:
                msg = f"MITRange {r!s} is not contained in stored range {self.range!s}."
                raise IndexError(msg)
            j = self._col_index(c)
            return TSeries(r.start, self._values[i0 : i1 + 1, j], copy=True)

        # MITRange + collection → MVTSeries.
        if isinstance(r, MITRange) and isinstance(c, (list, tuple)):
            self._check_freq_for(r.frequency, op="indexing")
            i0 = self._row_index(r.start)
            i1 = self._row_index(r.last())
            if i0 < 0 or i1 >= self._values.shape[0]:
                msg = f"MITRange {r!s} is not contained in stored range {self.range!s}."
                raise IndexError(msg)
            block_inds = np.asarray([self._col_index(str(n)) for n in c], dtype=np.intp)
            names = [str(n) for n in c]
            sub = self._values[i0 : i1 + 1][:, block_inds].copy()
            return MVTSeries(r.start, names, sub)

        # MIT-slice form: ``mvts[a:b, c]``.
        if isinstance(r, slice) and (isinstance(r.start, MIT) or isinstance(r.stop, MIT)):
            return self._getitem_two(self._slice_to_range(r), c)

        # Integer pass-throughs.
        if _is_int_like(r) and _is_int_like(c):
            return self._values[int(r), int(c)]
        if _is_int_like(r):
            return self._values[int(r), c]
        if _is_int_like(c):
            return self._values[r, int(c)]
        return self._values[r, c]

    def _slice_with_mit(self, key: slice) -> Any:
        return self._getitem_one(self._slice_to_range(key))

    def _slice_to_range(self, key: slice) -> MITRange:
        if not (isinstance(key.start, MIT) and isinstance(key.stop, MIT)):
            msg = "MIT slice must have MIT endpoints on both sides."
            raise TypeError(msg)
        step = key.step if key.step is not None else 1
        return MITRange(key.start, key.stop, step)

    def _is_name_collection(self, t: tuple[Any, ...]) -> bool:
        if len(t) == 0:
            return False
        # Heuristic: a name collection contains only string-like elements.
        return all(isinstance(x, str) for x in t)

    def _check_columns_exist(self, names: list[str]) -> None:
        unknown = [n for n in names if n not in self._columns]
        if unknown:
            msg = f"MVTSeries has no column(s) {unknown!r}."
            raise KeyError(msg)

    # -- indexing: setitem -------------------------------------------------

    def __setitem__(self, key: Any, value: Any) -> None:
        if isinstance(key, tuple) and len(key) == 2 and not _is_name_tuple(key):
            self._setitem_two(key[0], key[1], value)
            return
        self._setitem_one(key, value)

    def _setitem_one(self, key: Any, value: Any) -> None:
        if isinstance(key, str):
            self._set_column(key, value)
            return
        if isinstance(key, MIT):
            self._check_freq_for(key.frequency, op="setting")
            i = self._row_index(key)
            if not 0 <= i < self._values.shape[0]:
                msg = f"MIT {key!s} is outside the stored range {self.range!s}."
                raise IndexError(msg)
            self._values[i, :] = value
            return
        if isinstance(key, MITRange):
            self._check_freq_for(key.frequency, op="setting")
            i0 = self._row_index(key.start)
            i1 = self._row_index(key.last())
            if i0 < 0 or i1 >= self._values.shape[0]:
                msg = f"MITRange {key!s} is not contained in stored range {self.range!s}."
                raise IndexError(msg)
            self._assign_row_block(i0, i1, slice(None), value, src_range=key)
            return
        if isinstance(key, (list, tuple)) and self._is_name_collection(tuple(key)):
            names = [str(n) for n in key]
            self._check_columns_exist(names)
            inds = [self._col_index(n) for n in names]
            self._assign_col_block(inds, value, names=names)
            return
        if isinstance(key, np.ndarray) and key.dtype == np.bool_:
            if key.ndim == 1 and key.shape[0] == self._values.shape[0]:
                self._values[key, :] = np.asarray(value)
                return
            self._values[key] = value
            return
        if isinstance(key, list) and all(isinstance(b, (bool, np.bool_)) for b in key):
            self._setitem_one(np.asarray(key, dtype=bool), value)
            return
        if isinstance(key, slice):
            if isinstance(key.start, MIT) or isinstance(key.stop, MIT):
                self._setitem_one(self._slice_to_range(key), value)
                return
            self._values[key] = value
            return
        if _is_int_like(key):
            self._values[int(key)] = value
            return
        if isinstance(key, np.ndarray) and np.issubdtype(key.dtype, np.integer):
            self._values[key] = value
            return
        msg = f"MVTSeries does not support indexing with {type(key).__name__}."
        raise TypeError(msg)

    def _setitem_two(self, r: Any, c: Any, value: Any) -> None:
        # Colon shortcuts.
        if isinstance(r, slice) and r == slice(None) and isinstance(c, slice) and c == slice(None):
            self._values[:, :] = value
            return
        if isinstance(r, slice) and r == slice(None):
            self._setitem_one(c, value)
            return
        if isinstance(c, slice) and c == slice(None):
            self._setitem_one(r, value)
            return

        # Bool row-mask cases.
        if isinstance(r, np.ndarray) and r.dtype == np.bool_ and r.ndim == 1:
            if isinstance(c, str):
                col_idx = self._col_index(c)
                self._values[r, col_idx] = value
                return
            if isinstance(c, (list, tuple)):
                col_inds_set = np.asarray([self._col_index(str(n)) for n in c], dtype=np.intp)
                self._values[np.ix_(r, col_inds_set)] = value
                return
            if isinstance(c, np.ndarray) and c.dtype == np.bool_:
                self._values[np.ix_(r, c)] = value
                return

        # MIT + str.
        if isinstance(r, MIT) and isinstance(c, str):
            self._check_freq_for(r.frequency, op="setting")
            i = self._row_index(r)
            j = self._col_index(c)
            self._values[i, j] = value
            return

        # MIT + collection.
        if isinstance(r, MIT) and isinstance(c, (list, tuple)):
            self._check_freq_for(r.frequency, op="setting")
            i = self._row_index(r)
            row_col_inds = np.asarray([self._col_index(str(n)) for n in c], dtype=np.intp)
            arr = np.asarray(value)
            if arr.ndim == 0:
                msg = (
                    "Cannot assign scalar across multiple columns by tuple; use a list "
                    "with explicit broadcast or `mvts[MIT, name] = scalar`."
                )
                raise ValueError(msg)
            self._values[i, row_col_inds] = arr
            return

        # MITRange + str → assign a TSeries-aligned column slice.
        if isinstance(r, MITRange) and isinstance(c, str):
            self._check_freq_for(r.frequency, op="setting")
            i0 = self._row_index(r.start)
            i1 = self._row_index(r.last())
            if i0 < 0 or i1 >= self._values.shape[0]:
                msg = f"MITRange {r!s} is not contained in stored range {self.range!s}."
                raise IndexError(msg)
            j = self._col_index(c)
            if isinstance(value, TSeries):
                self._check_freq_for(value.frequency, op="setting")
                self._values[i0 : i1 + 1, j] = value[r].values
                return
            self._values[i0 : i1 + 1, j] = np.asarray(value)
            return

        # MITRange + collection.
        if isinstance(r, MITRange) and isinstance(c, (list, tuple)):
            self._check_freq_for(r.frequency, op="setting")
            i0 = self._row_index(r.start)
            i1 = self._row_index(r.last())
            if i0 < 0 or i1 >= self._values.shape[0]:
                msg = f"MITRange {r!s} is not contained in stored range {self.range!s}."
                raise IndexError(msg)
            names = [str(n) for n in c]
            block_col_inds = np.asarray([self._col_index(n) for n in names], dtype=np.intp)
            row_idx = np.arange(i0, i1 + 1, dtype=np.intp)
            if isinstance(value, MVTSeries):
                self._check_freq_for(value.frequency, op="setting")
                arr = np.column_stack(
                    [value[name][r].values for name in names if name in value._columns]
                )
                self._values[np.ix_(row_idx, block_col_inds)] = arr
                return
            arr2 = np.asarray(value)
            if arr2.ndim == 1:
                arr2 = arr2.reshape(-1, 1)
            self._values[np.ix_(row_idx, block_col_inds)] = arr2
            return

        # MIT-slice → range.
        if isinstance(r, slice) and (isinstance(r.start, MIT) or isinstance(r.stop, MIT)):
            self._setitem_two(self._slice_to_range(r), c, value)
            return

        # Integer pass-through.
        if _is_int_like(r) and _is_int_like(c):
            self._values[int(r), int(c)] = value
            return
        if _is_int_like(r):
            self._values[int(r), c] = value
            return
        if _is_int_like(c):
            self._values[r, int(c)] = value
            return
        self._values[r, c] = value

    def _set_column(self, name: str, value: Any) -> None:
        """Implement Julia's ``setproperty!(x, name, val)`` for an existing column."""
        if name not in self._columns:
            msg = (
                f"Cannot create new column {name!r}. Build a new MVTSeries with the "
                f"additional column."
            )
            raise KeyError(msg)
        col_idx = self._col_index(name)
        if isinstance(value, TSeries):
            self._check_freq_for(value.frequency, op="setting")
            # Align by MIT — only the overlap is written; the rest is left alone.
            lo = max(self._firstdate.value, value.firstdate.value)
            hi = min(self.lastdate.value, value.lastdate.value)
            if lo > hi:
                return
            i0 = lo - self._firstdate.value
            i1 = hi - self._firstdate.value
            src_off = lo - value.firstdate.value
            n = hi - lo + 1
            self._values[i0 : i1 + 1, col_idx] = value.values[src_off : src_off + n]
            return
        if isinstance(value, MVTSeries):
            self._check_freq_for(value.frequency, op="setting")
            if name not in value._columns:
                msg = f"Right-hand MVTSeries has no column {name!r}."
                raise KeyError(msg)
            self._set_column(name, value._columns[name])
            return
        if _is_scalar_number(value):
            self._values[:, col_idx] = value
            return
        arr = np.asarray(value)
        if arr.ndim != 1 or arr.shape[0] != self._values.shape[0]:
            msg = (
                f"Column assignment: expected a length-{self._values.shape[0]} vector, "
                f"got shape {arr.shape}."
            )
            raise ValueError(msg)
        self._values[:, col_idx] = arr

    def _assign_row_block(
        self,
        i0: int,
        i1: int,
        col_sel: Any,
        value: Any,
        *,
        src_range: MITRange | None = None,
    ) -> None:
        if isinstance(value, MVTSeries):
            self._check_freq_for(value.frequency, op="setting")
            # Only common columns are written (mirrors Julia
            # ``setindex!(x::MVTSeries, val::MVTSeries, rng)``).
            for name, src_col in value._columns.items():
                if name in self._columns:
                    j = self._col_index(name)
                    rng = src_range if src_range is not None else value.range
                    self._values[i0 : i1 + 1, j] = src_col[rng].values
            return
        if isinstance(value, TSeries):
            self._check_freq_for(value.frequency, op="setting")
            rng = src_range if src_range is not None else value.range
            aligned = value[rng].values
            self._values[i0 : i1 + 1, col_sel] = aligned
            return
        self._values[i0 : i1 + 1, col_sel] = value

    def _assign_col_block(
        self,
        col_inds: list[int],
        value: Any,
        *,
        names: list[str],
    ) -> None:
        del names  # currently only used for diagnostics; left for future use
        if isinstance(value, MVTSeries):
            self._check_freq_for(value.frequency, op="setting")
            rng = self.range
            arr = np.column_stack(
                [value[name][rng].values for name in self.column_names if name in value._columns]
            )
            self._values[:, col_inds[: arr.shape[1]]] = arr
            return
        arr2 = np.asarray(value)
        if arr2.ndim == 1:
            arr2 = arr2.reshape(-1, 1)
        self._values[:, col_inds] = arr2

    # -- NumPy interop -----------------------------------------------------

    def __array__(
        self,
        dtype: npt.DTypeLike | None = None,
        copy: bool | None = None,
    ) -> np.ndarray:
        """Return the underlying matrix (frequency / column names dropped)."""
        if dtype is None or np.dtype(dtype) == self._values.dtype:
            if copy:
                return self._values.copy()
            return self._values
        if copy is False:
            msg = "Cannot honor copy=False when a dtype conversion is required."
            raise ValueError(msg)
        return self._values.astype(dtype)

    def __array_ufunc__(
        self,
        ufunc: np.ufunc,
        method: str,
        *inputs: Any,
        out: Any = None,
        **kwargs: Any,
    ) -> Any:
        # Defer out-parameter handling until a real caller needs it.
        if out is not None:
            return NotImplemented

        # np.matmul has shape semantics (n?,k),(k,m?)->(n?,m?) that don't fit
        # the element-wise range-intersection path below. Route to linalg.py
        # to match Julia's linalg.jl behavior (strip labels, return ndarray).
        if ufunc is np.matmul and method == "__call__" and len(inputs) == 2:
            return _matmul_strip(inputs[0], inputs[1])

        if method != "__call__":
            # Reductions / accumulations / outer / etc. unwrap to bare ndarray.
            arrays = tuple(np.asarray(x) if isinstance(x, MVTSeries) else x for x in inputs)
            return getattr(ufunc, method)(*arrays, **kwargs)

        return _mvts_dispatch_ufunc(ufunc, inputs, kwargs)

    def __array_function__(
        self,
        func: Any,
        types: tuple[type, ...],
        args: tuple[Any, ...],
        kwargs: dict[str, Any],
    ) -> Any:
        handler = _ARRAY_FUNCTION_HANDLERS.get(func)
        if handler is not None:
            return handler(*args, **kwargs)
        # Fallback: unwrap MVTSeries → ndarray, call the function, return raw.
        unwrapped_args = tuple(np.asarray(a) if isinstance(a, MVTSeries) else a for a in args)
        unwrapped_kwargs = {
            k: (np.asarray(v) if isinstance(v, MVTSeries) else v) for k, v in kwargs.items()
        }
        return func(*unwrapped_args, **unwrapped_kwargs)

    # -- arithmetic dunders ------------------------------------------------
    # ndarray operands hit __array_ufunc__ via ndarray's __add__/etc. For
    # bare scalars and TSeries, route to the same np.* ufunc to keep one path.

    def __add__(self, other: Any) -> Any:
        return np.add(self, other)

    def __radd__(self, other: Any) -> Any:
        return np.add(other, self)

    def __sub__(self, other: Any) -> Any:
        return np.subtract(self, other)

    def __rsub__(self, other: Any) -> Any:
        return np.subtract(other, self)

    def __mul__(self, other: Any) -> Any:
        return np.multiply(self, other)

    def __rmul__(self, other: Any) -> Any:
        return np.multiply(other, self)

    def __matmul__(self, other: Any) -> Any:
        # Match Julia's linalg.jl: strip labels, return a plain ndarray.
        # `@` is the PEP 465 spelling of Julia's `*` matrix-product overload.
        return _matmul_strip(self, other)

    def __rmatmul__(self, other: Any) -> Any:
        return _matmul_strip(other, self)

    def __truediv__(self, other: Any) -> Any:
        return np.true_divide(self, other)

    def __rtruediv__(self, other: Any) -> Any:
        return np.true_divide(other, self)

    def __floordiv__(self, other: Any) -> Any:
        return np.floor_divide(self, other)

    def __rfloordiv__(self, other: Any) -> Any:
        return np.floor_divide(other, self)

    def __mod__(self, other: Any) -> Any:
        return np.remainder(self, other)

    def __pow__(self, other: Any) -> Any:
        return np.power(self, other)

    def __rpow__(self, other: Any) -> Any:
        return np.power(other, self)

    def __neg__(self) -> MVTSeries:
        return _build_like(self, np.negative(self._values))

    def __pos__(self) -> MVTSeries:
        return self.copy()

    def __abs__(self) -> MVTSeries:
        return _build_like(self, np.absolute(self._values))

    # -- in-place compound assignment -------------------------------------
    # ``mvts += rhs`` rewrites the matrix buffer in place when shapes line up;
    # column views (which share that buffer) continue to track the update.

    def __iadd__(self, other: Any) -> MVTSeries:
        return self._inplace(np.add, other)

    def __isub__(self, other: Any) -> MVTSeries:
        return self._inplace(np.subtract, other)

    def __imul__(self, other: Any) -> MVTSeries:
        return self._inplace(np.multiply, other)

    def __itruediv__(self, other: Any) -> MVTSeries:
        return self._inplace(np.true_divide, other)

    def __ipow__(self, other: Any) -> MVTSeries:
        return self._inplace(np.power, other)

    def _inplace(self, ufunc: np.ufunc, other: Any) -> MVTSeries:
        if isinstance(other, MVTSeries):
            self._check_freq_for(other.frequency, op=ufunc.__name__)
            rng, cols = _intersect_axes(self, other)
            n = len(rng)
            if n == 0 or not cols:
                # No rows in common or no shared columns — nothing to update.
                return self
            self_off = rng.start.value - self._firstdate.value
            other_off = rng.start.value - other._firstdate.value
            # Write each shared column directly so we hit the matrix buffer
            # (fancy indexing on a 2-D ndarray returns a copy, which would
            # silently break the in-place semantics).
            for name in cols:
                sj = self._col_index(name)
                oj = other._col_index(name)
                sub_self = self._values[self_off : self_off + n, sj]
                sub_other = other._values[other_off : other_off + n, oj]
                ufunc(sub_self, sub_other, out=sub_self)
            return self
        if isinstance(other, TSeries):
            self._check_freq_for(other.frequency, op=ufunc.__name__)
            lo = max(self._firstdate.value, other.firstdate.value)
            hi = min(self.lastdate.value, other.lastdate.value)
            if lo > hi:
                return self
            self_off = lo - self._firstdate.value
            other_off = lo - other.firstdate.value
            n = hi - lo + 1
            sub_self = self._values[self_off : self_off + n]
            sub_other = other.values[other_off : other_off + n][:, np.newaxis]
            ufunc(sub_self, sub_other, out=sub_self)
            return self
        rhs = np.asarray(other) if not isinstance(other, np.ndarray) else other
        if rhs.ndim == 0 or rhs.shape == self._values.shape:
            ufunc(self._values, rhs, out=self._values)
            return self
        # Row-broadcast (shape (ncols,) or (1, ncols)): NumPy handles this natively.
        if rhs.ndim == 1 and rhs.shape[0] == self._values.shape[1]:
            ufunc(self._values, rhs[np.newaxis, :], out=self._values)
            return self
        if rhs.shape == (1, self._values.shape[1]) or rhs.shape == (self._values.shape[0], 1):
            ufunc(self._values, rhs, out=self._values)
            return self
        msg = (
            f"In-place {ufunc.__name__}: right-hand shape {rhs.shape} is not compatible "
            f"with MVTSeries shape {self._values.shape}."
        )
        raise ValueError(msg)

    # -- whole-object comparison ------------------------------------------

    def equals(self, other: object) -> bool:
        """Return True iff ``other`` is an MVTSeries with same freq/range/cols/values."""
        if not isinstance(other, MVTSeries):
            return False
        if self.frequency != other.frequency:
            return False
        if self._firstdate != other._firstdate:
            return False
        if tuple(self._columns.keys()) != tuple(other._columns.keys()):
            return False
        return np.array_equal(self._values, other._values, equal_nan=False)

    def allclose(self, other: object, *, rtol: float = 1e-5, atol: float = 1e-8) -> bool:
        """Return True iff ``other`` is a same-shape MVTSeries with close values."""
        if not isinstance(other, MVTSeries):
            return False
        if self.frequency != other.frequency:
            return False
        if self._firstdate != other._firstdate or self.shape != other.shape:
            return False
        if tuple(self._columns.keys()) != tuple(other._columns.keys()):
            return False
        return bool(np.allclose(self._values, other._values, rtol=rtol, atol=atol, equal_nan=True))

    # -- equality / hash --------------------------------------------------

    def __eq__(self, other: object) -> Any:
        # Elementwise comparison (NumPy semantics), matching the Julia
        # AbstractMatrix convention. Use ``.equals()`` for whole-object
        # structural equality. The NumPy ufunc stubs reject our scalar /
        # ndarray operand union — the runtime call is fine, the type
        # ignore narrows for mypy only.
        if isinstance(other, MVTSeries):
            return np.equal(self._values, other._values)
        if isinstance(other, np.ndarray):
            return np.equal(self._values, other)
        if _is_scalar_number(other):
            return np.equal(self._values, other)  # type: ignore[call-overload]
        return NotImplemented

    __hash__: ClassVar[None] = None  # type: ignore[assignment]

    # -- repr --------------------------------------------------------------

    def __repr__(self) -> str:
        return _format_mvtseries(self)

    def __str__(self) -> str:
        return _format_mvtseries(self)

    def _repr_html_(self) -> str:
        return _repr_html_mvtseries(self)

values property

values: ndarray

The underlying 2-D NumPy array. Mutating it mutates the MVTSeries.

firstdate property

firstdate: MIT

The MIT of the first stored row.

lastdate property

lastdate: MIT

The MIT of the last stored row. Undefined when the MVTSeries is empty.

frequency property

frequency: Frequency

The shared frequency of all rows.

range property

range: MITRange

The MITRange covering firstdate..lastdate.

For an empty MVTSeries (no rows) returns an empty MITRange.

column_names property

column_names: tuple[str, ...]

The column names in insertion order.

columns property

columns: dict[str, TSeries]

The column-name → TSeries-view mapping (live; do not mutate keys).

dtype property

dtype: dtype[Any]

The dtype of the underlying matrix.

shape property

shape: tuple[int, int]

Shape of the underlying matrix (nrows, ncols).

ndim property

ndim: int

Number of dimensions (always 2).

empty classmethod

empty(
    rng: MITRange,
    names: _NamesLike = (),
    *,
    dtype: DTypeLike = np.float64,
) -> MVTSeries

Construct an uninitialized MVTSeries (typenan-filled).

Source code in src/tsecon/mvtseries.py
@classmethod
def empty(
    cls,
    rng: MITRange,
    names: _NamesLike = (),
    *,
    dtype: npt.DTypeLike = np.float64,
) -> MVTSeries:
    """Construct an uninitialized MVTSeries (typenan-filled)."""
    return cls(rng, names, dtype=dtype)

zeros classmethod

zeros(
    rng: MITRange,
    names: _NamesLike,
    *,
    dtype: DTypeLike = np.float64,
) -> MVTSeries

Construct an MVTSeries of zeros.

Source code in src/tsecon/mvtseries.py
@classmethod
def zeros(
    cls,
    rng: MITRange,
    names: _NamesLike,
    *,
    dtype: npt.DTypeLike = np.float64,
) -> MVTSeries:
    """Construct an MVTSeries of zeros."""
    return cls(rng, names, 0, dtype=dtype)

ones classmethod

ones(
    rng: MITRange,
    names: _NamesLike,
    *,
    dtype: DTypeLike = np.float64,
) -> MVTSeries

Construct an MVTSeries of ones.

Source code in src/tsecon/mvtseries.py
@classmethod
def ones(
    cls,
    rng: MITRange,
    names: _NamesLike,
    *,
    dtype: npt.DTypeLike = np.float64,
) -> MVTSeries:
    """Construct an MVTSeries of ones."""
    return cls(rng, names, 1, dtype=dtype)

fill classmethod

fill(
    rng: MITRange,
    names: _NamesLike,
    value: float | int | bool,
    *,
    dtype: DTypeLike | None = None,
) -> MVTSeries

Construct an MVTSeries filled with value.

Source code in src/tsecon/mvtseries.py
@classmethod
def fill(
    cls,
    rng: MITRange,
    names: _NamesLike,
    value: float | int | bool,
    *,
    dtype: npt.DTypeLike | None = None,
) -> MVTSeries:
    """Construct an MVTSeries filled with ``value``."""
    return cls(rng, names, value, dtype=dtype)

keys

keys() -> tuple[str, ...]

Return the column names (insertion order).

Source code in src/tsecon/mvtseries.py
def keys(self) -> tuple[str, ...]:
    """Return the column names (insertion order)."""
    return tuple(self._columns.keys())

is_empty

is_empty() -> bool

Return True iff the MVTSeries has no rows or no columns.

Source code in src/tsecon/mvtseries.py
def is_empty(self) -> bool:
    """Return True iff the MVTSeries has no rows or no columns."""
    return bool(self._values.shape[0] == 0 or self._values.shape[1] == 0)

copy

copy(*, deep: bool = False) -> MVTSeries

Return an independent copy with its own matrix buffer.

deep is accepted for API uniformity with :meth:~tsecon.tseries.TSeries.copy and :meth:~tsecon.workspace.Workspace.copy; for MVTSeries it is a semantic no-op because the only mutable referents below the wrapper are the matrix buffer (always copied) and the per-column TSeries views (which are rebuilt to point at the fresh buffer).

Source code in src/tsecon/mvtseries.py
def copy(self, *, deep: bool = False) -> MVTSeries:
    """Return an independent copy with its own matrix buffer.

    ``deep`` is accepted for API uniformity with
    :meth:`~tsecon.tseries.TSeries.copy` and
    :meth:`~tsecon.workspace.Workspace.copy`; for MVTSeries it is a
    semantic no-op because the only mutable referents below the
    wrapper are the matrix buffer (always copied) and the per-column
    TSeries views (which are rebuilt to point at the fresh buffer).
    """
    del deep  # accepted for uniformity; see docstring
    return MVTSeries(
        self._firstdate,
        list(self._columns.keys()),
        self._values.copy(),
    )

similar

similar(
    *,
    dtype: DTypeLike | None = None,
    rng: MITRange | None = None,
    names: _NamesLike | None = None,
) -> MVTSeries

Return an uninitialized MVTSeries with matching shape (or overrides).

Source code in src/tsecon/mvtseries.py
def similar(
    self,
    *,
    dtype: npt.DTypeLike | None = None,
    rng: MITRange | None = None,
    names: _NamesLike | None = None,
) -> MVTSeries:
    """Return an uninitialized MVTSeries with matching shape (or overrides)."""
    out_rng = rng if rng is not None else self.range
    out_names = list(self._columns.keys()) if names is None else _names_as_list(names)
    out_dtype = dtype if dtype is not None else self._values.dtype
    return MVTSeries.empty(out_rng, out_names, dtype=out_dtype)

equals

equals(other: object) -> bool

Return True iff other is an MVTSeries with same freq/range/cols/values.

Source code in src/tsecon/mvtseries.py
def equals(self, other: object) -> bool:
    """Return True iff ``other`` is an MVTSeries with same freq/range/cols/values."""
    if not isinstance(other, MVTSeries):
        return False
    if self.frequency != other.frequency:
        return False
    if self._firstdate != other._firstdate:
        return False
    if tuple(self._columns.keys()) != tuple(other._columns.keys()):
        return False
    return np.array_equal(self._values, other._values, equal_nan=False)

allclose

allclose(
    other: object,
    *,
    rtol: float = 1e-05,
    atol: float = 1e-08,
) -> bool

Return True iff other is a same-shape MVTSeries with close values.

Source code in src/tsecon/mvtseries.py
def allclose(self, other: object, *, rtol: float = 1e-5, atol: float = 1e-8) -> bool:
    """Return True iff ``other`` is a same-shape MVTSeries with close values."""
    if not isinstance(other, MVTSeries):
        return False
    if self.frequency != other.frequency:
        return False
    if self._firstdate != other._firstdate or self.shape != other.shape:
        return False
    if tuple(self._columns.keys()) != tuple(other._columns.keys()):
        return False
    return bool(np.allclose(self._values, other._values, rtol=rtol, atol=atol, equal_nan=True))

BackendNotAvailableError

Bases: ImportError

Raised when a requested plotting backend is not installed.

Source code in src/tsecon/plotting/__init__.py
class BackendNotAvailableError(ImportError):
    """Raised when a requested plotting backend is not installed."""

TSeries

A 1-D NumPy array paired with a frequency-tagged firstdate.

Construct with TSeries(firstdate, values) where firstdate is an :class:~tsecon.mit.MIT or :class:~tsecon.mitrange.MITRange. When the first argument is an MITRange, the second may be omitted (uninitialized, NaN-filled storage), a scalar (fill), or a length-matching array. See the class-level constructors :meth:empty, :meth:zeros, :meth:ones, :meth:fill for keyword-named convenience constructors.

Source code in src/tsecon/tseries.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
class TSeries:
    """A 1-D NumPy array paired with a frequency-tagged ``firstdate``.

    Construct with ``TSeries(firstdate, values)`` where ``firstdate`` is an
    :class:`~tsecon.mit.MIT` or :class:`~tsecon.mitrange.MITRange`. When the
    first argument is an MITRange, the second may be omitted (uninitialized,
    NaN-filled storage), a scalar (fill), or a length-matching array. See
    the class-level constructors :meth:`empty`, :meth:`zeros`, :meth:`ones`,
    :meth:`fill` for keyword-named convenience constructors.
    """

    __slots__ = ("_firstdate", "_values")

    # Bump priority above ndarray so that ``5 + ts`` dispatches through
    # ``TSeries.__array_ufunc__`` rather than the bare ndarray's __radd__.
    __array_priority__: ClassVar[float] = 1000.0

    _firstdate: MIT
    _values: np.ndarray

    # -- construction ------------------------------------------------------

    def __init__(
        self,
        firstdate_or_range: MIT | MITRange,
        values: _ArrayLike | float | int | bool | Callable[[int], np.ndarray] | None = None,
        *,
        dtype: npt.DTypeLike | None = None,
        copy: bool = False,
    ) -> None:
        """Construct a TSeries.

        Parameters
        ----------
        firstdate_or_range : MIT or MITRange
            Either the MIT of the first stored entry (with ``values`` supplying
            the data) or an MITRange covering the whole series.
        values : array-like, scalar, callable, or None
            * ``None`` (only with an MITRange) — uninitialized storage filled
              with the dtype's NaN sentinel.
            * Scalar — fill the range with the scalar.
            * Array-like — wrap (or copy, with ``copy=True``) as the underlying
              buffer. Length must match the range.
            * Callable (only with an MITRange) — invoked as ``values(len(rng))``;
              the result must be a 1-D ``ndarray`` of matching length. Mirrors
              Julia's ``TSeries(rng, ini::Function)`` (e.g.,
              ``TSeries(rng, np.zeros)`` / ``TSeries(rng, np.ones)``).

        Notes
        -----
        Passing an already-compatible ``ndarray`` as ``values`` *wraps* the
        buffer rather than copying it (matching xarray's ``DataArray``).
        Set ``copy=True`` to force an independent allocation, or call
        :meth:`copy` / :func:`copy.deepcopy` post-construction. The
        wrap-vs-copy contract extends to the callable form: the array returned
        by the callable is the user's, so the default is to wrap.

        Examples
        --------
        >>> rng = MITRange(qq(2020, 1), qq(2020, 4))
        >>> TSeries(rng, np.zeros).values
        array([0., 0., 0., 0.])
        >>> TSeries(rng, np.ones).values
        array([1., 1., 1., 1.])
        """
        if isinstance(firstdate_or_range, MITRange):
            rng = firstdate_or_range
            length = len(rng)
            if callable(values) and not isinstance(values, (np.ndarray, Sequence)):
                result = values(length)
                if not isinstance(result, np.ndarray) or result.ndim != 1:
                    msg = (
                        f"TSeries(rng, callable): callable returned "
                        f"{type(result).__name__} of "
                        f"ndim={getattr(result, 'ndim', '?')}; "
                        f"expected 1-D ndarray of length {length}."
                    )
                    raise ValueError(msg)
                if len(result) != length:
                    msg = (
                        f"TSeries(rng, callable): callable returned length "
                        f"{len(result)}; expected {length} (matching range)."
                    )
                    raise ValueError(msg)
                values = result
            if values is None:
                target_dtype = np.dtype(dtype) if dtype is not None else np.dtype(np.float64)
                arr = np.full(length, typenan(target_dtype), dtype=target_dtype)
            elif _is_scalar(values):
                target_dtype = np.dtype(dtype) if dtype is not None else np.asarray(values).dtype
                arr = np.full(length, values, dtype=target_dtype)
            else:
                arr = _coerce_values(values, dtype=dtype, copy=copy)
                if arr.shape[0] != length:
                    msg = (
                        f"Range and data lengths mismatch: range has {length} entries, "
                        f"got {arr.shape[0]}."
                    )
                    raise ValueError(msg)
            self._firstdate = rng.start
            self._values = arr
            return

        if isinstance(firstdate_or_range, MIT):
            fd = firstdate_or_range
            if values is None:
                target_dtype = np.dtype(dtype) if dtype is not None else np.dtype(np.float64)
                arr = np.empty(0, dtype=target_dtype)
            elif _is_scalar(values):
                msg = (
                    "TSeries(MIT, scalar) is ambiguous; pass a length-matching array, "
                    "or use TSeries(MITRange, scalar) to fill a range."
                )
                raise TypeError(msg)
            else:
                arr = _coerce_values(values, dtype=dtype, copy=copy)
            self._firstdate = fd
            self._values = arr
            return

        msg = (  # type: ignore[unreachable]
            f"TSeries first argument must be MIT or MITRange, "
            f"got {type(firstdate_or_range).__name__}."
        )
        raise TypeError(msg)

    # -- alternate constructors -------------------------------------------

    @classmethod
    def empty(cls, rng: MITRange, *, dtype: npt.DTypeLike = np.float64) -> TSeries:
        """Construct an uninitialized TSeries over ``rng`` filled with the dtype's NaN."""
        return cls(rng, dtype=dtype)

    @classmethod
    def fill(
        cls,
        rng: MITRange,
        value: float | int | bool,
        *,
        dtype: npt.DTypeLike | None = None,
    ) -> TSeries:
        """Construct a TSeries over ``rng`` filled with ``value``."""
        return cls(rng, value, dtype=dtype)

    @classmethod
    def zeros(cls, rng: MITRange, *, dtype: npt.DTypeLike = np.float64) -> TSeries:
        """Construct a TSeries of zeros over ``rng``."""
        return cls(rng, 0, dtype=dtype)

    @classmethod
    def ones(cls, rng: MITRange, *, dtype: npt.DTypeLike = np.float64) -> TSeries:
        """Construct a TSeries of ones over ``rng``."""
        return cls(rng, 1, dtype=dtype)

    @classmethod
    def trues(cls, rng: MITRange) -> TSeries:
        """Construct a boolean TSeries of ``True`` values over ``rng``."""
        return cls(rng, True, dtype=np.bool_)

    @classmethod
    def falses(cls, rng: MITRange) -> TSeries:
        """Construct a boolean TSeries of ``False`` values over ``rng``."""
        return cls(rng, False, dtype=np.bool_)

    # -- accessors ---------------------------------------------------------

    @property
    def values(self) -> np.ndarray:
        """The underlying 1-D NumPy array. Mutating it mutates the TSeries."""
        return self._values

    @property
    def firstdate(self) -> MIT:
        """The MIT of the first stored entry."""
        return self._firstdate

    @property
    def lastdate(self) -> MIT:
        """The MIT of the last stored entry. Undefined for empty TSeries."""
        return MIT(self._firstdate.frequency, self._firstdate.value + len(self._values) - 1)

    @property
    def frequency(self) -> Frequency:
        """The shared frequency of all entries."""
        return self._firstdate.frequency

    @property
    def range(self) -> MITRange:
        """The MITRange covering ``firstdate..lastdate``.

        Empty TSeries return an empty MITRange (``start > stop``).
        """
        if len(self._values) == 0:
            empty_stop = MIT(self._firstdate.frequency, self._firstdate.value - 1)
            return MITRange(self._firstdate, empty_stop)
        return MITRange(self._firstdate, self.lastdate)

    @property
    def dtype(self) -> np.dtype[Any]:
        """The dtype of the underlying array."""
        return self._values.dtype

    @property
    def shape(self) -> tuple[int, ...]:
        """Shape of the underlying array (always 1-D)."""
        return self._values.shape

    @property
    def ndim(self) -> int:
        """Number of dimensions (always 1)."""
        return 1

    # -- size / iteration --------------------------------------------------

    def __len__(self) -> int:
        return int(self._values.shape[0])

    def __iter__(self) -> Iterator[Any]:
        return iter(self._values)

    def __bool__(self) -> bool:
        msg = (
            "The truth value of a TSeries is ambiguous. Use .equals(), .allclose(), "
            ".any(), .all(), or len()."
        )
        raise ValueError(msg)

    def is_empty(self) -> bool:
        """Return True iff the underlying array has length zero."""
        return len(self._values) == 0

    def any(self) -> bool:
        """Return ``bool(self.values.any())``."""
        return bool(self._values.any())

    def all(self) -> bool:
        """Return ``bool(self.values.all())``."""
        return bool(self._values.all())

    # -- copy / similar ----------------------------------------------------

    def copy(self, *, deep: bool = False) -> TSeries:
        """Return an independent copy with its own storage.

        The ``deep`` kwarg is accepted for API uniformity with container
        types (:class:`~tsecon.workspace.Workspace`, MVTSeries) where a
        shallow copy would share value references. TSeries has no nested
        containers — the underlying ndarray is always copied — so
        ``deep=True`` is a semantic no-op here.
        """
        del deep  # accepted for uniformity; see docstring
        return TSeries(self._firstdate, self._values.copy())

    def __copy__(self) -> TSeries:
        return self.copy()

    def __deepcopy__(self, memo: dict[int, Any]) -> TSeries:
        new = self.copy()
        memo[id(self)] = new
        return new

    def similar(
        self,
        rng: MITRange | None = None,
        *,
        dtype: npt.DTypeLike | None = None,
    ) -> TSeries:
        """Return an uninitialized TSeries with matching shape (or the given range)."""
        if rng is None:
            rng = self.range
        target_dtype = dtype if dtype is not None else self._values.dtype
        return TSeries.empty(rng, dtype=target_dtype)

    # -- frequency / range helpers -----------------------------------------

    def _check_freq(self, other_freq: Frequency, *, op: str) -> None:
        if self._firstdate.frequency != other_freq:
            msg = (
                f"Mixing frequencies not allowed in {op}: "
                f"{_freq_label(self)} and {_freq_label(other_freq)}."
            )
            raise TypeError(msg)

    def _mit_to_index(self, m: MIT) -> int:
        return int(m.value - self._firstdate.value)

    # -- resize ------------------------------------------------------------

    def resize(self, rng: MITRange) -> TSeries:
        """Extend or shrink storage so the new range equals ``rng``.

        New entries are filled with the dtype's NaN sentinel. The TSeries is
        modified in place; the same object is returned for chaining.
        """
        self._check_freq(rng.frequency, op="resize")
        if rng.step != 1:
            msg = "resize requires a unit-step MITRange."
            raise ValueError(msg)
        new_len = len(rng)
        nan_val = typenan(self._values.dtype)
        if rng.start == self._firstdate:
            old_len = len(self._values)
            if new_len == old_len:
                return self
            new_arr = np.full(new_len, nan_val, dtype=self._values.dtype)
            keep = min(new_len, old_len)
            if keep > 0:
                new_arr[:keep] = self._values[:keep]
            self._values = new_arr
            return self
        # General case: align by MIT.
        new_arr = np.full(new_len, nan_val, dtype=self._values.dtype)
        old_range = self.range
        overlap_start_value = max(old_range.start.value, rng.start.value)
        overlap_stop_value = min(old_range.stop.value, rng.stop.value)
        if overlap_start_value <= overlap_stop_value:
            old_offset = overlap_start_value - self._firstdate.value
            new_offset = overlap_start_value - rng.start.value
            n = overlap_stop_value - overlap_start_value + 1
            new_arr[new_offset : new_offset + n] = self._values[old_offset : old_offset + n]
        self._values = new_arr
        self._firstdate = rng.start
        return self

    def _ensure_covers(self, rng: MITRange) -> None:
        """Extend storage in place so the range covers union(self.range, rng)."""
        if self._firstdate.frequency != rng.frequency:
            raise _mixed_freq_error(self, rng)
        span = rangeof_span(self.range, rng)
        if span != self.range:
            self.resize(span)

    # -- indexing: getitem -------------------------------------------------

    def __getitem__(self, key: Any) -> Any:
        if isinstance(key, MIT):
            self._check_freq(key.frequency, op="indexing")
            i = self._mit_to_index(key)
            if not 0 <= i < len(self._values):
                msg = f"MIT {key!s} is outside the stored range {self.range!s}."
                raise IndexError(msg)
            return self._values[i]
        if isinstance(key, MITRange):
            self._check_freq(key.frequency, op="indexing")
            i0 = self._mit_to_index(key.start)
            i1 = self._mit_to_index(key.last())
            if i0 < 0 or i1 >= len(self._values):
                msg = f"MITRange {key!s} is not contained in stored range {self.range!s}."
                raise IndexError(msg)
            if key.step == 1:
                # ts[range] returns a TSeries with its own buffer (per decision 16
                # scope note): pass copy=True so the slice view doesn't escape.
                return TSeries(key.start, self._values[i0 : i1 + 1], copy=True)
            return self._values[i0 : i1 + 1 : key.step].copy()
        if isinstance(key, slice):
            if isinstance(key.start, MIT) or isinstance(key.stop, MIT):
                if key.step is not None and not isinstance(key.step, int):
                    msg = "MIT slice step must be an integer."
                    raise TypeError(msg)
                if not (isinstance(key.start, MIT) and isinstance(key.stop, MIT)):
                    msg = "MIT slice must have MIT endpoints on both sides (no implicit begin/end)."
                    raise TypeError(msg)
                step = key.step if key.step is not None else 1
                return self[MITRange(key.start, key.stop, step)]
            return self._values[key].copy()
        if isinstance(key, bool):
            msg = "TSeries indexing with a bare bool is ambiguous."
            raise TypeError(msg)
        if isinstance(key, (int, np.integer)):
            return self._values[int(key)]
        if isinstance(key, TSeries):
            self._check_freq(key.frequency, op="boolean indexing")
            if key.frequency != self.frequency:
                raise _mixed_freq_error(self, key)
            if key.range != self.range:
                msg = "Boolean TSeries mask must have the same range as the indexed TSeries."
                raise IndexError(msg)
            if key._values.dtype != np.bool_:
                msg = "Boolean TSeries indexing requires a TSeries with dtype=bool."
                raise TypeError(msg)
            return self._values[key._values]
        arr = np.asarray(key)
        if arr.dtype == np.bool_:
            return self._values[arr]
        if np.issubdtype(arr.dtype, np.integer):
            return self._values[arr]
        msg = f"TSeries does not support indexing with {type(key).__name__}."
        raise TypeError(msg)

    # -- indexing: setitem -------------------------------------------------

    def __setitem__(self, key: Any, value: Any) -> None:
        if isinstance(key, MIT):
            self._check_freq(key.frequency, op="setting")
            if not (
                self._firstdate.value <= key.value <= self._firstdate.value + len(self._values) - 1
            ):
                self._ensure_covers(MITRange(key, key))
            i = self._mit_to_index(key)
            self._values[i] = value
            return
        if isinstance(key, MITRange):
            self._check_freq(key.frequency, op="setting")
            self._ensure_covers(key)
            i0 = self._mit_to_index(key.start)
            i1 = self._mit_to_index(key.last())
            slot = slice(i0, i1 + 1, key.step if key.step != 1 else None)
            if isinstance(value, TSeries):
                self._check_freq(value.frequency, op="setting")
                # Align the source by MIT.
                aligned = value[key]
                self._values[slot] = aligned.values if isinstance(aligned, TSeries) else aligned
                return
            self._values[slot] = value
            return
        if isinstance(key, slice):
            if isinstance(key.start, MIT) or isinstance(key.stop, MIT):
                if not (isinstance(key.start, MIT) and isinstance(key.stop, MIT)):
                    msg = "MIT slice must have MIT endpoints on both sides (no implicit begin/end)."
                    raise TypeError(msg)
                step = key.step if key.step is not None else 1
                self[MITRange(key.start, key.stop, step)] = value
                return
            self._values[key] = value
            return
        if isinstance(key, bool):
            msg = "TSeries indexing with a bare bool is ambiguous."
            raise TypeError(msg)
        if isinstance(key, (int, np.integer)):
            self._values[int(key)] = value
            return
        if isinstance(key, TSeries):
            self._check_freq(key.frequency, op="boolean indexing")
            if key.range != self.range:
                msg = "Boolean TSeries mask must have the same range as the indexed TSeries."
                raise IndexError(msg)
            if key._values.dtype != np.bool_:
                msg = "Boolean TSeries indexing requires a TSeries with dtype=bool."
                raise TypeError(msg)
            self._values[key._values] = value
            return
        arr = np.asarray(key)
        if arr.dtype == np.bool_:
            self._values[arr] = value
            return
        if np.issubdtype(arr.dtype, np.integer):
            self._values[arr] = value
            return
        msg = f"TSeries does not support indexing with {type(key).__name__}."
        raise TypeError(msg)

    # -- NumPy protocols ---------------------------------------------------

    def __array__(self, dtype: npt.DTypeLike | None = None, copy: bool | None = None) -> np.ndarray:
        """Return the underlying values array (frequency information is dropped).

        NumPy ``2.x`` passes ``copy`` to honor ``np.array(ts, copy=...)``. We
        respect it: ``copy=True`` returns an independent array, ``copy=False``
        attempts a zero-copy view and only succeeds when no dtype conversion is
        required.
        """
        if dtype is None or np.dtype(dtype) == self._values.dtype:
            if copy:
                return self._values.copy()
            return self._values
        if copy is False:
            msg = "Cannot honor copy=False when a dtype conversion is required."
            raise ValueError(msg)
        return self._values.astype(dtype)

    def __array_ufunc__(
        self,
        ufunc: np.ufunc,
        method: str,
        *inputs: Any,
        out: Any = None,
        **kwargs: Any,
    ) -> Any:
        # Out-parameter handling is deferred until a real use case emerges.
        if out is not None:
            return NotImplemented

        # If any input is an MVTSeries, defer to its dispatcher (mirrors the
        # Julia precedence: MVTSeriesStyle > TSeriesStyle when mixed).
        for x in inputs:
            if x is not self and type(x).__name__ == "MVTSeries":
                return NotImplemented

        # np.matmul has shape semantics (n?,k),(k,m?)->(n?,m?) that don't fit
        # the element-wise range-intersection path below. Route to linalg.py
        # to match Julia's linalg.jl behavior (strip labels, return ndarray).
        if ufunc is np.matmul and method == "__call__" and len(inputs) == 2:
            return _matmul_strip(inputs[0], inputs[1])

        # Reduce / accumulate / outer / etc. — apply to the bare ndarray.
        if method != "__call__":
            arrays = tuple(np.asarray(x) if isinstance(x, TSeries) else x for x in inputs)
            return getattr(ufunc, method)(*arrays, **kwargs)

        # Compute the common range across TSeries inputs (intersection).
        tseries_inputs = [x for x in inputs if isinstance(x, TSeries)]
        if not tseries_inputs:
            return NotImplemented  # pragma: no cover  (numpy wouldn't call us)

        common_freq = tseries_inputs[0].frequency
        for t in tseries_inputs[1:]:
            if t.frequency != common_freq:
                raise _mixed_freq_error(tseries_inputs[0], t)

        start_value = max(t._firstdate.value for t in tseries_inputs)
        stop_value = min(t.lastdate.value for t in tseries_inputs)

        if start_value > stop_value:
            # Empty intersection → empty TSeries.
            empty_rng = MITRange(
                MIT(common_freq, start_value),
                MIT(common_freq, start_value - 1),
            )
            return TSeries(empty_rng)
        common_range = MITRange(MIT(common_freq, start_value), MIT(common_freq, stop_value))
        n = len(common_range)

        materialized: list[Any] = []
        for x in inputs:
            if isinstance(x, TSeries):
                off = start_value - x._firstdate.value
                materialized.append(x._values[off : off + n])
                continue
            if isinstance(x, np.ndarray):
                if x.ndim == 0 or (x.ndim == 1 and x.shape[0] == n):
                    materialized.append(x)
                    continue
                msg = f"Cannot broadcast array of shape {x.shape} against a TSeries of length {n}."
                raise ValueError(msg)
            materialized.append(x)

        result = ufunc(*materialized, **kwargs)
        if isinstance(result, tuple):
            return tuple(_maybe_wrap(r, common_range) for r in result)
        return _maybe_wrap(result, common_range)

    def __array_function__(
        self,
        func: Any,
        types: tuple[type, ...],
        args: tuple[Any, ...],
        kwargs: dict[str, Any],
    ) -> Any:
        handler = _ARRAY_FUNCTION_HANDLERS.get(func)
        if handler is not None:
            return handler(*args, **kwargs)
        # Fallback: unwrap TSeries → ndarray, call the function, return raw.
        unwrapped_args = tuple(np.asarray(a) if isinstance(a, TSeries) else a for a in args)
        unwrapped_kwargs = {
            k: (np.asarray(v) if isinstance(v, TSeries) else v) for k, v in kwargs.items()
        }
        return func(*unwrapped_args, **unwrapped_kwargs)

    # -- arithmetic dunders ------------------------------------------------
    # NumPy dispatches arithmetic for ndarray operands through __array_ufunc__
    # via ndarray's own __add__/etc. For non-ndarray scalars, Python calls our
    # __add__ first; we route to np.add to keep one code path. Similarly for
    # __r*__.

    def __add__(self, other: Any) -> Any:
        return np.add(self, other)

    def __radd__(self, other: Any) -> Any:
        return np.add(other, self)

    def __sub__(self, other: Any) -> Any:
        return np.subtract(self, other)

    def __rsub__(self, other: Any) -> Any:
        return np.subtract(other, self)

    def __mul__(self, other: Any) -> Any:
        return np.multiply(self, other)

    def __rmul__(self, other: Any) -> Any:
        return np.multiply(other, self)

    def __matmul__(self, other: Any) -> Any:
        # Match Julia's linalg.jl: strip labels, return a plain ndarray.
        # `@` is the PEP 465 spelling of Julia's `*` matrix-product overload.
        return _matmul_strip(self, other)

    def __rmatmul__(self, other: Any) -> Any:
        return _matmul_strip(other, self)

    def __truediv__(self, other: Any) -> Any:
        return np.true_divide(self, other)

    def __rtruediv__(self, other: Any) -> Any:
        return np.true_divide(other, self)

    def __floordiv__(self, other: Any) -> Any:
        return np.floor_divide(self, other)

    def __rfloordiv__(self, other: Any) -> Any:
        return np.floor_divide(other, self)

    def __mod__(self, other: Any) -> Any:
        return np.remainder(self, other)

    def __pow__(self, other: Any) -> Any:
        return np.power(self, other)

    def __rpow__(self, other: Any) -> Any:
        return np.power(other, self)

    def __neg__(self) -> TSeries:
        return TSeries(self._firstdate, np.negative(self._values))

    def __pos__(self) -> TSeries:
        return self.copy()

    def __abs__(self) -> TSeries:
        return TSeries(self._firstdate, np.absolute(self._values))

    # -- elementwise comparisons (NumPy semantics) -------------------------
    # The np.* ufuncs accept TSeries because __array_ufunc__ takes over the
    # dispatch, but mypy's stubs don't know that; per-call ignores are the
    # least invasive option (see decision 02).

    def __eq__(self, other: object) -> Any:
        if not _is_compatible_operand(other):
            return NotImplemented
        return np.equal(self, other)  # type: ignore[call-overload]

    def __ne__(self, other: object) -> Any:
        if not _is_compatible_operand(other):
            return NotImplemented
        return np.not_equal(self, other)  # type: ignore[call-overload]

    def __lt__(self, other: object) -> Any:
        if not _is_compatible_operand(other):
            return NotImplemented
        return np.less(self, other)  # type: ignore[call-overload]

    def __le__(self, other: object) -> Any:
        if not _is_compatible_operand(other):
            return NotImplemented
        return np.less_equal(self, other)  # type: ignore[call-overload]

    def __gt__(self, other: object) -> Any:
        if not _is_compatible_operand(other):
            return NotImplemented
        return np.greater(self, other)  # type: ignore[call-overload]

    def __ge__(self, other: object) -> Any:
        if not _is_compatible_operand(other):
            return NotImplemented
        return np.greater_equal(self, other)  # type: ignore[call-overload]

    __hash__: ClassVar[None] = None  # type: ignore[assignment]

    # -- whole-object comparison ------------------------------------------

    def equals(self, other: object) -> bool:
        """Return True iff ``other`` is a TSeries with identical freq, range, and values."""
        if not isinstance(other, TSeries):
            return False
        if self.frequency != other.frequency:
            return False
        if self._firstdate != other._firstdate:
            return False
        return np.array_equal(self._values, other._values, equal_nan=False)

    def allclose(self, other: object, *, rtol: float = 1e-5, atol: float = 1e-8) -> bool:
        """Return True iff ``other`` is a same-shape TSeries with close values."""
        if not isinstance(other, TSeries):
            return False
        if self.frequency != other.frequency:
            return False
        if self._firstdate != other._firstdate or len(self) != len(other):
            return False
        return bool(np.allclose(self._values, other._values, rtol=rtol, atol=atol, equal_nan=True))

    # -- repr / str --------------------------------------------------------

    def __repr__(self) -> str:
        return _format_tseries(self)

    def __str__(self) -> str:
        return _format_tseries(self)

    def _repr_html_(self) -> str:
        return _repr_html_tseries(self)

values property

values: ndarray

The underlying 1-D NumPy array. Mutating it mutates the TSeries.

firstdate property

firstdate: MIT

The MIT of the first stored entry.

lastdate property

lastdate: MIT

The MIT of the last stored entry. Undefined for empty TSeries.

frequency property

frequency: Frequency

The shared frequency of all entries.

range property

range: MITRange

The MITRange covering firstdate..lastdate.

Empty TSeries return an empty MITRange (start > stop).

dtype property

dtype: dtype[Any]

The dtype of the underlying array.

shape property

shape: tuple[int, ...]

Shape of the underlying array (always 1-D).

ndim property

ndim: int

Number of dimensions (always 1).

empty classmethod

empty(
    rng: MITRange, *, dtype: DTypeLike = np.float64
) -> TSeries

Construct an uninitialized TSeries over rng filled with the dtype's NaN.

Source code in src/tsecon/tseries.py
@classmethod
def empty(cls, rng: MITRange, *, dtype: npt.DTypeLike = np.float64) -> TSeries:
    """Construct an uninitialized TSeries over ``rng`` filled with the dtype's NaN."""
    return cls(rng, dtype=dtype)

fill classmethod

fill(
    rng: MITRange,
    value: float | int | bool,
    *,
    dtype: DTypeLike | None = None,
) -> TSeries

Construct a TSeries over rng filled with value.

Source code in src/tsecon/tseries.py
@classmethod
def fill(
    cls,
    rng: MITRange,
    value: float | int | bool,
    *,
    dtype: npt.DTypeLike | None = None,
) -> TSeries:
    """Construct a TSeries over ``rng`` filled with ``value``."""
    return cls(rng, value, dtype=dtype)

zeros classmethod

zeros(
    rng: MITRange, *, dtype: DTypeLike = np.float64
) -> TSeries

Construct a TSeries of zeros over rng.

Source code in src/tsecon/tseries.py
@classmethod
def zeros(cls, rng: MITRange, *, dtype: npt.DTypeLike = np.float64) -> TSeries:
    """Construct a TSeries of zeros over ``rng``."""
    return cls(rng, 0, dtype=dtype)

ones classmethod

ones(
    rng: MITRange, *, dtype: DTypeLike = np.float64
) -> TSeries

Construct a TSeries of ones over rng.

Source code in src/tsecon/tseries.py
@classmethod
def ones(cls, rng: MITRange, *, dtype: npt.DTypeLike = np.float64) -> TSeries:
    """Construct a TSeries of ones over ``rng``."""
    return cls(rng, 1, dtype=dtype)

trues classmethod

trues(rng: MITRange) -> TSeries

Construct a boolean TSeries of True values over rng.

Source code in src/tsecon/tseries.py
@classmethod
def trues(cls, rng: MITRange) -> TSeries:
    """Construct a boolean TSeries of ``True`` values over ``rng``."""
    return cls(rng, True, dtype=np.bool_)

falses classmethod

falses(rng: MITRange) -> TSeries

Construct a boolean TSeries of False values over rng.

Source code in src/tsecon/tseries.py
@classmethod
def falses(cls, rng: MITRange) -> TSeries:
    """Construct a boolean TSeries of ``False`` values over ``rng``."""
    return cls(rng, False, dtype=np.bool_)

is_empty

is_empty() -> bool

Return True iff the underlying array has length zero.

Source code in src/tsecon/tseries.py
def is_empty(self) -> bool:
    """Return True iff the underlying array has length zero."""
    return len(self._values) == 0

any

any() -> bool

Return bool(self.values.any()).

Source code in src/tsecon/tseries.py
def any(self) -> bool:
    """Return ``bool(self.values.any())``."""
    return bool(self._values.any())

all

all() -> bool

Return bool(self.values.all()).

Source code in src/tsecon/tseries.py
def all(self) -> bool:
    """Return ``bool(self.values.all())``."""
    return bool(self._values.all())

copy

copy(*, deep: bool = False) -> TSeries

Return an independent copy with its own storage.

The deep kwarg is accepted for API uniformity with container types (:class:~tsecon.workspace.Workspace, MVTSeries) where a shallow copy would share value references. TSeries has no nested containers — the underlying ndarray is always copied — so deep=True is a semantic no-op here.

Source code in src/tsecon/tseries.py
def copy(self, *, deep: bool = False) -> TSeries:
    """Return an independent copy with its own storage.

    The ``deep`` kwarg is accepted for API uniformity with container
    types (:class:`~tsecon.workspace.Workspace`, MVTSeries) where a
    shallow copy would share value references. TSeries has no nested
    containers — the underlying ndarray is always copied — so
    ``deep=True`` is a semantic no-op here.
    """
    del deep  # accepted for uniformity; see docstring
    return TSeries(self._firstdate, self._values.copy())

similar

similar(
    rng: MITRange | None = None,
    *,
    dtype: DTypeLike | None = None,
) -> TSeries

Return an uninitialized TSeries with matching shape (or the given range).

Source code in src/tsecon/tseries.py
def similar(
    self,
    rng: MITRange | None = None,
    *,
    dtype: npt.DTypeLike | None = None,
) -> TSeries:
    """Return an uninitialized TSeries with matching shape (or the given range)."""
    if rng is None:
        rng = self.range
    target_dtype = dtype if dtype is not None else self._values.dtype
    return TSeries.empty(rng, dtype=target_dtype)

resize

resize(rng: MITRange) -> TSeries

Extend or shrink storage so the new range equals rng.

New entries are filled with the dtype's NaN sentinel. The TSeries is modified in place; the same object is returned for chaining.

Source code in src/tsecon/tseries.py
def resize(self, rng: MITRange) -> TSeries:
    """Extend or shrink storage so the new range equals ``rng``.

    New entries are filled with the dtype's NaN sentinel. The TSeries is
    modified in place; the same object is returned for chaining.
    """
    self._check_freq(rng.frequency, op="resize")
    if rng.step != 1:
        msg = "resize requires a unit-step MITRange."
        raise ValueError(msg)
    new_len = len(rng)
    nan_val = typenan(self._values.dtype)
    if rng.start == self._firstdate:
        old_len = len(self._values)
        if new_len == old_len:
            return self
        new_arr = np.full(new_len, nan_val, dtype=self._values.dtype)
        keep = min(new_len, old_len)
        if keep > 0:
            new_arr[:keep] = self._values[:keep]
        self._values = new_arr
        return self
    # General case: align by MIT.
    new_arr = np.full(new_len, nan_val, dtype=self._values.dtype)
    old_range = self.range
    overlap_start_value = max(old_range.start.value, rng.start.value)
    overlap_stop_value = min(old_range.stop.value, rng.stop.value)
    if overlap_start_value <= overlap_stop_value:
        old_offset = overlap_start_value - self._firstdate.value
        new_offset = overlap_start_value - rng.start.value
        n = overlap_stop_value - overlap_start_value + 1
        new_arr[new_offset : new_offset + n] = self._values[old_offset : old_offset + n]
    self._values = new_arr
    self._firstdate = rng.start
    return self

equals

equals(other: object) -> bool

Return True iff other is a TSeries with identical freq, range, and values.

Source code in src/tsecon/tseries.py
def equals(self, other: object) -> bool:
    """Return True iff ``other`` is a TSeries with identical freq, range, and values."""
    if not isinstance(other, TSeries):
        return False
    if self.frequency != other.frequency:
        return False
    if self._firstdate != other._firstdate:
        return False
    return np.array_equal(self._values, other._values, equal_nan=False)

allclose

allclose(
    other: object,
    *,
    rtol: float = 1e-05,
    atol: float = 1e-08,
) -> bool

Return True iff other is a same-shape TSeries with close values.

Source code in src/tsecon/tseries.py
def allclose(self, other: object, *, rtol: float = 1e-5, atol: float = 1e-8) -> bool:
    """Return True iff ``other`` is a same-shape TSeries with close values."""
    if not isinstance(other, TSeries):
        return False
    if self.frequency != other.frequency:
        return False
    if self._firstdate != other._firstdate or len(self) != len(other):
        return False
    return bool(np.allclose(self._values, other._values, rtol=rtol, atol=atol, equal_nan=True))

Workspace

Insertion-ordered, name-indexed collection of arbitrary values.

Construct with one of:

  • Workspace() — empty.
  • Workspace(a=1, b=2) — from keyword arguments.
  • Workspace({"a": 1, "b": 2}) — from a mapping.
  • Workspace([("a", 1), ("b", 2)]) — from an iterable of pairs.

Access by attribute (w.a) or by key (w["a"]); subset by tuple or list of keys (w["a", "b"] or w[["a", "b"]]) to get a new Workspace.

Keys must be strings. (Julia uses Symbol; we standardize on str since Python has no analogue and attribute access naturally produces strings.)

Source code in src/tsecon/workspace.py
class Workspace:
    """Insertion-ordered, name-indexed collection of arbitrary values.

    Construct with one of:

    * ``Workspace()`` — empty.
    * ``Workspace(a=1, b=2)`` — from keyword arguments.
    * ``Workspace({"a": 1, "b": 2})`` — from a mapping.
    * ``Workspace([("a", 1), ("b", 2)])`` — from an iterable of pairs.

    Access by attribute (``w.a``) or by key (``w["a"]``); subset by tuple or
    list of keys (``w["a", "b"]`` or ``w[["a", "b"]]``) to get a new Workspace.

    Keys must be strings. (Julia uses ``Symbol``; we standardize on ``str``
    since Python has no analogue and attribute access naturally produces
    strings.)
    """

    __slots__ = ("_c",)

    _c: dict[str, Any]

    # -- construction ------------------------------------------------------

    def __init__(
        self,
        items: Mapping[str, Any] | Workspace | Iterable[tuple[str, Any]] | None = None,
        /,
        **kwargs: Any,
    ) -> None:
        # bypass __setattr__ which routes to _c
        object.__setattr__(self, "_c", {})
        if items is not None:
            if isinstance(items, (Mapping, Workspace)):
                for k, v in items.items():
                    self._c[str(k)] = v
            else:
                for k, v in items:
                    self._c[str(k)] = v
        for k, v in kwargs.items():
            self._c[k] = v

    @classmethod
    def from_dict(cls, d: Mapping[Any, Any], *, recursive: bool = False) -> Workspace:
        """Build a Workspace from a mapping.

        With ``recursive=True``, any nested ``Mapping`` value is itself
        converted to a Workspace, recursively. Mirrors the Julia
        ``Workspace(d; recursive=true)`` constructor.
        """
        out = cls()
        for k, v in d.items():
            value = cls.from_dict(v, recursive=True) if recursive and isinstance(v, Mapping) else v
            out._c[str(k)] = value
        return out

    # -- attribute access (dot notation) -----------------------------------

    def __getattr__(self, name: str) -> Any:
        # Only called when normal attribute lookup fails.
        try:
            return object.__getattribute__(self, "_c")[name]
        except KeyError as e:
            msg = f"Workspace has no member {name!r}"
            raise AttributeError(msg) from e

    def __setattr__(self, name: str, value: Any) -> None:
        if name in _RESERVED:
            object.__setattr__(self, name, value)
            return
        self._c[name] = value

    def __delattr__(self, name: str) -> None:
        if name in _RESERVED:
            msg = f"{name!r} is reserved on Workspace."
            raise AttributeError(msg)
        try:
            del self._c[name]
        except KeyError as e:
            raise AttributeError(name) from e

    def __dir__(self) -> list[str]:
        # Surface member names for tab completion / IDE introspection.
        return [*object.__dir__(self), *self._c.keys()]

    # -- key access --------------------------------------------------------

    def __getitem__(self, key: Any) -> Any:
        if isinstance(key, str):
            try:
                return self._c[key]
            except KeyError as e:
                raise KeyError(key) from e
        if isinstance(key, (tuple, list)):
            return Workspace({str(k): self._c[str(k)] for k in key})
        msg = f"Workspace keys must be str or tuple/list of str, got {type(key).__name__}."
        raise TypeError(msg)

    def __setitem__(self, key: str, value: Any) -> None:
        if not isinstance(key, str):
            msg = f"Workspace keys must be str, got {type(key).__name__}."  # type: ignore[unreachable]
            raise TypeError(msg)
        self._c[key] = value

    def __delitem__(self, key: str) -> None:
        del self._c[key]

    # -- dict-like introspection -------------------------------------------

    def __len__(self) -> int:
        return len(self._c)

    def __iter__(self) -> Iterator[str]:
        return iter(self._c)

    def __contains__(self, key: object) -> bool:
        return isinstance(key, str) and key in self._c

    def keys(self) -> KeysView[str]:
        """Return a view of the member names."""
        return self._c.keys()

    def values(self) -> ValuesView[Any]:
        """Return a view of the member values."""
        return self._c.values()

    def items(self) -> ItemsView[str, Any]:
        """Return a view of the (name, value) pairs."""
        return self._c.items()

    def get(self, key: str, default: Any = None) -> Any:
        """Return ``self[key]`` if present, otherwise ``default``."""
        return self._c.get(key, default)

    def is_empty(self) -> bool:
        """Return True iff the Workspace has no members."""
        return len(self._c) == 0

    # -- namespace exposure ------------------------------------------------

    @contextmanager
    def as_namespace(self) -> Iterator[SimpleNamespace]:
        """Yield a snapshot of members as attributes on a ``SimpleNamespace``.

        Use as a context manager to scope unprefixed access to the
        Workspace's members::

            with w.as_namespace() as ns:
                y = ns.a + ns.b

        The snapshot is taken at ``__enter__``: subsequent mutations to
        either side are independent. Members whose keys are not valid
        Python identifiers are silently omitted (they cannot be reached
        via attribute syntax anyway — use ``w["..."]`` for those). Values
        are held by reference, so mutating a contained TSeries / MVTSeries
        through the namespace also mutates the same object in ``self``.

        The Pythonic alternative to Julia's ``@weval(W, EXPR)`` macro.
        For most call sites direct attribute access ``w.a + w.b`` is
        shorter and clearer; reach for ``as_namespace`` only when several
        unprefixed reads in a row earn their keep over the explicit prefix.
        """
        valid = {k: v for k, v in self._c.items() if k.isidentifier()}
        yield SimpleNamespace(**valid)

    # -- mutation helpers --------------------------------------------------

    def merge(self, other: Workspace | Mapping[str, Any]) -> Workspace:
        """Return a new Workspace with ``other``'s entries overlaid onto a copy of ``self``.

        Mirrors Julia's ``merge(a, b)``. Keys in ``other`` win over keys in
        ``self``.
        """
        out = self.copy()
        out.merge_inplace(other)
        return out

    def merge_inplace(self, other: Workspace | Mapping[str, Any]) -> Workspace:
        """Update ``self`` in place with entries from ``other`` (other wins).

        Returns ``self`` for chaining. Mirrors Julia's ``merge!(a, b)``.
        """
        src = other._c if isinstance(other, Workspace) else other
        for k, v in src.items():
            self._c[str(k)] = v
        return self

    def empty_inplace(self) -> Workspace:
        """Remove all entries. Mirrors Julia's ``empty!(w)``."""
        self._c.clear()
        return self

    def copy(self, *, deep: bool = False) -> Workspace:
        """Return a copy of the Workspace.

        With ``deep=False`` (the default) the storage dict is fresh but
        values are shared by reference — Python-dict semantics. With
        ``deep=True``, every value is recursively :func:`copy.deepcopy`-ed,
        equivalent to ``copy.deepcopy(self)``. The kwarg matches the
        :meth:`TSeries.copy` signature for API uniformity.
        """
        if deep:
            return deepcopy(self)
        out = Workspace()
        out._c.update(self._c)
        return out

    def __copy__(self) -> Workspace:
        return self.copy()

    def __deepcopy__(self, memo: dict[int, Any]) -> Workspace:
        out = Workspace()
        memo[id(self)] = out
        out._c.update({k: deepcopy(v, memo) for k, v in self._c.items()})
        return out

    # -- filter / map ------------------------------------------------------

    def filter(self, predicate: Callable[[str, Any], bool]) -> Workspace:
        """Return a new Workspace containing only the (k, v) pairs that pass ``predicate``.

        Predicate is called as ``predicate(key, value)``. Mirrors Julia's
        ``filter(tuple -> ..., w)`` (where ``tuple`` is a ``(key, value)``
        pair).
        """
        return Workspace({k: v for k, v in self._c.items() if predicate(k, v)})

    def filter_inplace(self, predicate: Callable[[str, Any], bool]) -> Workspace:
        """Drop entries that fail ``predicate``; mutates and returns ``self``."""
        for k in [k for k, v in self._c.items() if not predicate(k, v)]:
            del self._c[k]
        return self

    def map(self, f: Callable[[Any], Any]) -> Workspace:
        """Apply ``f`` to each member's value; return a new Workspace.

        Keys are preserved. Mirrors Julia's ``map(f, w)``.
        """
        return Workspace({k: f(v) for k, v in self._c.items()})

    # -- strip -------------------------------------------------------------

    def strip_inplace(self, *, recursive: bool = True) -> Workspace:
        """Trim leading/trailing NaN values from every TSeries member.

        With ``recursive=True`` (the default) also strips TSeries inside
        nested Workspaces. Mirrors Julia's ``strip!(w; recursive)``.

        Delegates the per-TSeries trim to
        :func:`tsecon.fconvert.strip_tseries_inplace`. Bool-dtype TSeries are
        left alone (their typenan is ``False``, which would erase the array).
        """
        for value in self._c.values():
            if isinstance(value, TSeries):
                strip_tseries_inplace(value)
            elif recursive and isinstance(value, Workspace):
                value.strip_inplace(recursive=recursive)
        return self

    # -- range / frequency -------------------------------------------------

    def frequency_of(self, *, check: bool = False) -> Frequency | None:
        """Return the common frequency of all frequency-bearing members, or None.

        Recurses through nested Workspaces. If members carry distinct
        frequencies, returns None (or raises ``ValueError`` if
        ``check=True``). With ``check=True`` and no frequency-bearing
        members, also raises.
        """
        freqs: list[Frequency] = []
        for v in self._c.values():
            if not _has_frequency(v):
                continue
            f = _frequency_of_value(v)
            if f is not None:
                freqs.append(f)
        unique = {id(f): f for f in freqs}
        if len(unique) == 1:
            return next(iter(unique.values()))
        if check:
            if not freqs:
                msg = "The given workspace doesn't have a frequency."
                raise ValueError(msg)
            names = sorted({type(f).__name__ for f in freqs})
            msg = f"The given workspace has multiple frequencies: {', '.join(names)}."
            raise ValueError(msg)
        return None

    def rangeof(self, *, method: str = "intersect") -> MITRange:
        """Return the combined range of all rangeable members.

        Frequency-bearing members must share a single frequency, else
        ``TypeError`` is raised. Members without a range (scalars, strings,
        etc.) are skipped. If no member has a range, raises ``ValueError``.

        Parameters
        ----------
        method
            ``"intersect"`` (default) returns the intersection of all member
            ranges. ``"union"`` returns the spanning union, equivalent to
            :meth:`rangeof_span`. Mirrors Julia's ``rangeof(w; method=intersect)``
            policy switch.
        """
        if method == "union":
            return self.rangeof_span()
        if method != "intersect":
            msg = f"method must be 'intersect' or 'union', got {method!r}."
            raise ValueError(msg)
        ranges: list[MITRange] = []
        for v in self._c.values():
            r = _rangeof_member(v)
            if r is not None:
                ranges.append(r)
        if not ranges:
            msg = "Workspace has no rangeable members."
            raise ValueError(msg)
        # All must share a frequency, then intersect.
        head_freq = ranges[0].frequency
        for r in ranges[1:]:
            if r.frequency != head_freq:
                msg = (
                    f"Mixing frequencies not allowed: {prettyprint_frequency(head_freq)} "
                    f"and {prettyprint_frequency(r.frequency)}."
                )
                raise TypeError(msg)
        lo = max(r.start.value for r in ranges)
        hi = min(r.stop.value for r in ranges)
        return MITRange(MIT(head_freq, lo), MIT(head_freq, hi))

    def rangeof_span(self) -> MITRange:
        """Return the smallest range covering all rangeable members.

        Frequency must be consistent across rangeable members. Mirrors
        Julia's ``rangeof_span(values(w)...)`` idiom.
        """
        pieces: list[object] = []
        for v in self._c.values():
            if isinstance(v, Workspace):
                pieces.append(v.rangeof_span())
                continue
            sub = _rangeof_member(v)
            if sub is not None:
                pieces.append(sub)
        return rangeof_span(*pieces)

    # -- equality ----------------------------------------------------------

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Workspace):
            return NotImplemented
        if list(self._c.keys()) != list(other._c.keys()):
            return False
        return all(_values_equal(v, other._c[k]) for k, v in self._c.items())

    __hash__ = None  # type: ignore[assignment]

    # -- repr --------------------------------------------------------------

    def __repr__(self) -> str:
        return _format_workspace(self)

    def __str__(self) -> str:
        return _format_workspace(self)

    def _repr_html_(self) -> str:
        return _repr_html_workspace(self)

from_dict classmethod

from_dict(
    d: Mapping[Any, Any], *, recursive: bool = False
) -> Workspace

Build a Workspace from a mapping.

With recursive=True, any nested Mapping value is itself converted to a Workspace, recursively. Mirrors the Julia Workspace(d; recursive=true) constructor.

Source code in src/tsecon/workspace.py
@classmethod
def from_dict(cls, d: Mapping[Any, Any], *, recursive: bool = False) -> Workspace:
    """Build a Workspace from a mapping.

    With ``recursive=True``, any nested ``Mapping`` value is itself
    converted to a Workspace, recursively. Mirrors the Julia
    ``Workspace(d; recursive=true)`` constructor.
    """
    out = cls()
    for k, v in d.items():
        value = cls.from_dict(v, recursive=True) if recursive and isinstance(v, Mapping) else v
        out._c[str(k)] = value
    return out

keys

keys() -> KeysView[str]

Return a view of the member names.

Source code in src/tsecon/workspace.py
def keys(self) -> KeysView[str]:
    """Return a view of the member names."""
    return self._c.keys()

values

values() -> ValuesView[Any]

Return a view of the member values.

Source code in src/tsecon/workspace.py
def values(self) -> ValuesView[Any]:
    """Return a view of the member values."""
    return self._c.values()

items

items() -> ItemsView[str, Any]

Return a view of the (name, value) pairs.

Source code in src/tsecon/workspace.py
def items(self) -> ItemsView[str, Any]:
    """Return a view of the (name, value) pairs."""
    return self._c.items()

get

get(key: str, default: Any = None) -> Any

Return self[key] if present, otherwise default.

Source code in src/tsecon/workspace.py
def get(self, key: str, default: Any = None) -> Any:
    """Return ``self[key]`` if present, otherwise ``default``."""
    return self._c.get(key, default)

is_empty

is_empty() -> bool

Return True iff the Workspace has no members.

Source code in src/tsecon/workspace.py
def is_empty(self) -> bool:
    """Return True iff the Workspace has no members."""
    return len(self._c) == 0

as_namespace

as_namespace() -> Iterator[SimpleNamespace]

Yield a snapshot of members as attributes on a SimpleNamespace.

Use as a context manager to scope unprefixed access to the Workspace's members::

with w.as_namespace() as ns:
    y = ns.a + ns.b

The snapshot is taken at __enter__: subsequent mutations to either side are independent. Members whose keys are not valid Python identifiers are silently omitted (they cannot be reached via attribute syntax anyway — use w["..."] for those). Values are held by reference, so mutating a contained TSeries / MVTSeries through the namespace also mutates the same object in self.

The Pythonic alternative to Julia's @weval(W, EXPR) macro. For most call sites direct attribute access w.a + w.b is shorter and clearer; reach for as_namespace only when several unprefixed reads in a row earn their keep over the explicit prefix.

Source code in src/tsecon/workspace.py
@contextmanager
def as_namespace(self) -> Iterator[SimpleNamespace]:
    """Yield a snapshot of members as attributes on a ``SimpleNamespace``.

    Use as a context manager to scope unprefixed access to the
    Workspace's members::

        with w.as_namespace() as ns:
            y = ns.a + ns.b

    The snapshot is taken at ``__enter__``: subsequent mutations to
    either side are independent. Members whose keys are not valid
    Python identifiers are silently omitted (they cannot be reached
    via attribute syntax anyway — use ``w["..."]`` for those). Values
    are held by reference, so mutating a contained TSeries / MVTSeries
    through the namespace also mutates the same object in ``self``.

    The Pythonic alternative to Julia's ``@weval(W, EXPR)`` macro.
    For most call sites direct attribute access ``w.a + w.b`` is
    shorter and clearer; reach for ``as_namespace`` only when several
    unprefixed reads in a row earn their keep over the explicit prefix.
    """
    valid = {k: v for k, v in self._c.items() if k.isidentifier()}
    yield SimpleNamespace(**valid)

merge

merge(other: Workspace | Mapping[str, Any]) -> Workspace

Return a new Workspace with other's entries overlaid onto a copy of self.

Mirrors Julia's merge(a, b). Keys in other win over keys in self.

Source code in src/tsecon/workspace.py
def merge(self, other: Workspace | Mapping[str, Any]) -> Workspace:
    """Return a new Workspace with ``other``'s entries overlaid onto a copy of ``self``.

    Mirrors Julia's ``merge(a, b)``. Keys in ``other`` win over keys in
    ``self``.
    """
    out = self.copy()
    out.merge_inplace(other)
    return out

merge_inplace

merge_inplace(
    other: Workspace | Mapping[str, Any],
) -> Workspace

Update self in place with entries from other (other wins).

Returns self for chaining. Mirrors Julia's merge!(a, b).

Source code in src/tsecon/workspace.py
def merge_inplace(self, other: Workspace | Mapping[str, Any]) -> Workspace:
    """Update ``self`` in place with entries from ``other`` (other wins).

    Returns ``self`` for chaining. Mirrors Julia's ``merge!(a, b)``.
    """
    src = other._c if isinstance(other, Workspace) else other
    for k, v in src.items():
        self._c[str(k)] = v
    return self

empty_inplace

empty_inplace() -> Workspace

Remove all entries. Mirrors Julia's empty!(w).

Source code in src/tsecon/workspace.py
def empty_inplace(self) -> Workspace:
    """Remove all entries. Mirrors Julia's ``empty!(w)``."""
    self._c.clear()
    return self

copy

copy(*, deep: bool = False) -> Workspace

Return a copy of the Workspace.

With deep=False (the default) the storage dict is fresh but values are shared by reference — Python-dict semantics. With deep=True, every value is recursively :func:copy.deepcopy-ed, equivalent to copy.deepcopy(self). The kwarg matches the :meth:TSeries.copy signature for API uniformity.

Source code in src/tsecon/workspace.py
def copy(self, *, deep: bool = False) -> Workspace:
    """Return a copy of the Workspace.

    With ``deep=False`` (the default) the storage dict is fresh but
    values are shared by reference — Python-dict semantics. With
    ``deep=True``, every value is recursively :func:`copy.deepcopy`-ed,
    equivalent to ``copy.deepcopy(self)``. The kwarg matches the
    :meth:`TSeries.copy` signature for API uniformity.
    """
    if deep:
        return deepcopy(self)
    out = Workspace()
    out._c.update(self._c)
    return out

filter

filter(predicate: Callable[[str, Any], bool]) -> Workspace

Return a new Workspace containing only the (k, v) pairs that pass predicate.

Predicate is called as predicate(key, value). Mirrors Julia's filter(tuple -> ..., w) (where tuple is a (key, value) pair).

Source code in src/tsecon/workspace.py
def filter(self, predicate: Callable[[str, Any], bool]) -> Workspace:
    """Return a new Workspace containing only the (k, v) pairs that pass ``predicate``.

    Predicate is called as ``predicate(key, value)``. Mirrors Julia's
    ``filter(tuple -> ..., w)`` (where ``tuple`` is a ``(key, value)``
    pair).
    """
    return Workspace({k: v for k, v in self._c.items() if predicate(k, v)})

filter_inplace

filter_inplace(
    predicate: Callable[[str, Any], bool],
) -> Workspace

Drop entries that fail predicate; mutates and returns self.

Source code in src/tsecon/workspace.py
def filter_inplace(self, predicate: Callable[[str, Any], bool]) -> Workspace:
    """Drop entries that fail ``predicate``; mutates and returns ``self``."""
    for k in [k for k, v in self._c.items() if not predicate(k, v)]:
        del self._c[k]
    return self

map

map(f: Callable[[Any], Any]) -> Workspace

Apply f to each member's value; return a new Workspace.

Keys are preserved. Mirrors Julia's map(f, w).

Source code in src/tsecon/workspace.py
def map(self, f: Callable[[Any], Any]) -> Workspace:
    """Apply ``f`` to each member's value; return a new Workspace.

    Keys are preserved. Mirrors Julia's ``map(f, w)``.
    """
    return Workspace({k: f(v) for k, v in self._c.items()})

strip_inplace

strip_inplace(*, recursive: bool = True) -> Workspace

Trim leading/trailing NaN values from every TSeries member.

With recursive=True (the default) also strips TSeries inside nested Workspaces. Mirrors Julia's strip!(w; recursive).

Delegates the per-TSeries trim to :func:tsecon.fconvert.strip_tseries_inplace. Bool-dtype TSeries are left alone (their typenan is False, which would erase the array).

Source code in src/tsecon/workspace.py
def strip_inplace(self, *, recursive: bool = True) -> Workspace:
    """Trim leading/trailing NaN values from every TSeries member.

    With ``recursive=True`` (the default) also strips TSeries inside
    nested Workspaces. Mirrors Julia's ``strip!(w; recursive)``.

    Delegates the per-TSeries trim to
    :func:`tsecon.fconvert.strip_tseries_inplace`. Bool-dtype TSeries are
    left alone (their typenan is ``False``, which would erase the array).
    """
    for value in self._c.values():
        if isinstance(value, TSeries):
            strip_tseries_inplace(value)
        elif recursive and isinstance(value, Workspace):
            value.strip_inplace(recursive=recursive)
    return self

frequency_of

frequency_of(*, check: bool = False) -> Frequency | None

Return the common frequency of all frequency-bearing members, or None.

Recurses through nested Workspaces. If members carry distinct frequencies, returns None (or raises ValueError if check=True). With check=True and no frequency-bearing members, also raises.

Source code in src/tsecon/workspace.py
def frequency_of(self, *, check: bool = False) -> Frequency | None:
    """Return the common frequency of all frequency-bearing members, or None.

    Recurses through nested Workspaces. If members carry distinct
    frequencies, returns None (or raises ``ValueError`` if
    ``check=True``). With ``check=True`` and no frequency-bearing
    members, also raises.
    """
    freqs: list[Frequency] = []
    for v in self._c.values():
        if not _has_frequency(v):
            continue
        f = _frequency_of_value(v)
        if f is not None:
            freqs.append(f)
    unique = {id(f): f for f in freqs}
    if len(unique) == 1:
        return next(iter(unique.values()))
    if check:
        if not freqs:
            msg = "The given workspace doesn't have a frequency."
            raise ValueError(msg)
        names = sorted({type(f).__name__ for f in freqs})
        msg = f"The given workspace has multiple frequencies: {', '.join(names)}."
        raise ValueError(msg)
    return None

rangeof

rangeof(*, method: str = 'intersect') -> MITRange

Return the combined range of all rangeable members.

Frequency-bearing members must share a single frequency, else TypeError is raised. Members without a range (scalars, strings, etc.) are skipped. If no member has a range, raises ValueError.

Parameters:

Name Type Description Default
method str

"intersect" (default) returns the intersection of all member ranges. "union" returns the spanning union, equivalent to :meth:rangeof_span. Mirrors Julia's rangeof(w; method=intersect) policy switch.

'intersect'
Source code in src/tsecon/workspace.py
def rangeof(self, *, method: str = "intersect") -> MITRange:
    """Return the combined range of all rangeable members.

    Frequency-bearing members must share a single frequency, else
    ``TypeError`` is raised. Members without a range (scalars, strings,
    etc.) are skipped. If no member has a range, raises ``ValueError``.

    Parameters
    ----------
    method
        ``"intersect"`` (default) returns the intersection of all member
        ranges. ``"union"`` returns the spanning union, equivalent to
        :meth:`rangeof_span`. Mirrors Julia's ``rangeof(w; method=intersect)``
        policy switch.
    """
    if method == "union":
        return self.rangeof_span()
    if method != "intersect":
        msg = f"method must be 'intersect' or 'union', got {method!r}."
        raise ValueError(msg)
    ranges: list[MITRange] = []
    for v in self._c.values():
        r = _rangeof_member(v)
        if r is not None:
            ranges.append(r)
    if not ranges:
        msg = "Workspace has no rangeable members."
        raise ValueError(msg)
    # All must share a frequency, then intersect.
    head_freq = ranges[0].frequency
    for r in ranges[1:]:
        if r.frequency != head_freq:
            msg = (
                f"Mixing frequencies not allowed: {prettyprint_frequency(head_freq)} "
                f"and {prettyprint_frequency(r.frequency)}."
            )
            raise TypeError(msg)
    lo = max(r.start.value for r in ranges)
    hi = min(r.stop.value for r in ranges)
    return MITRange(MIT(head_freq, lo), MIT(head_freq, hi))

rangeof_span

rangeof_span() -> MITRange

Return the smallest range covering all rangeable members.

Frequency must be consistent across rangeable members. Mirrors Julia's rangeof_span(values(w)...) idiom.

Source code in src/tsecon/workspace.py
def rangeof_span(self) -> MITRange:
    """Return the smallest range covering all rangeable members.

    Frequency must be consistent across rangeable members. Mirrors
    Julia's ``rangeof_span(values(w)...)`` idiom.
    """
    pieces: list[object] = []
    for v in self._c.values():
        if isinstance(v, Workspace):
            pieces.append(v.rangeof_span())
            continue
        sub = _rangeof_member(v)
        if sub is not None:
            pieces.append(sub)
    return rangeof_span(*pieces)

bdvalues

bdvalues(
    t: TSeries | MVTSeries,
    *,
    holidays_map: TSeries | None = None,
) -> np.ndarray

Return values of t masked by holidays_map.

holidays_map must be a BDaily Boolean :class:~tsecon.tseries.TSeries that spans t.range. Entries where the map is False are dropped; the result is the underlying ndarray (1-D for TSeries, 2-D for MVTSeries) indexed by the Boolean mask.

Source code in src/tsecon/_bdaily.py
def bdvalues(t: TSeries | MVTSeries, *, holidays_map: TSeries | None = None) -> np.ndarray:
    """Return values of ``t`` masked by ``holidays_map``.

    ``holidays_map`` must be a BDaily Boolean :class:`~tsecon.tseries.TSeries`
    that spans ``t.range``. Entries where the map is ``False`` are dropped;
    the result is the underlying ndarray (1-D for TSeries, 2-D for MVTSeries)
    indexed by the Boolean mask.
    """
    _require_bdaily(t)
    if holidays_map is None:
        return np.asarray(t.values)
    _validate_holidays_arg(holidays_map)
    rng = t.range
    if rng.first() < holidays_map.firstdate or rng.last() > holidays_map.lastdate:
        msg = (
            "holidays_map does not cover the full range of the series: "
            f"series range {rng}, map range "
            f"{MITRange(holidays_map.firstdate, holidays_map.lastdate)}."
        )
        raise IndexError(msg)
    slice_ = holidays_map[rng]
    mask = np.asarray(slice_.values, dtype=bool)
    vals = np.asarray(t.values)
    if isinstance(t, MVTSeries):
        return np.asarray(vals[mask, :])
    return np.asarray(vals[mask])

cleanedvalues

cleanedvalues(
    t: TSeries | MVTSeries,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> np.ndarray

Return a filtered view of the underlying values of a BDaily series.

Behaviour mirrors Julia's cleanedvalues overloads:

  • holidays_map is given → use that map; entries where the map is False are dropped.
  • holidays_map is None and skip_all_nans=True → drop NaN entries. For an :class:~tsecon.mvtseries.MVTSeries, only rows where all columns are NaN are dropped; rows where some columns are NaN and some are not emit a :class:UserWarning and are also dropped.
  • holidays_map is None and skip_holidays=True → fetch the global map from getoption("bdaily_holidays_map"); raise if missing.
  • No kwargs → return the underlying values unchanged.

The 1-D return is t.values for a :class:~tsecon.tseries.TSeries; the 2-D return is mvts.values (an (rows, cols) view) for an :class:~tsecon.mvtseries.MVTSeries.

Source code in src/tsecon/_bdaily.py
def cleanedvalues(
    t: TSeries | MVTSeries,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> np.ndarray:
    """Return a filtered view of the underlying values of a BDaily series.

    Behaviour mirrors Julia's ``cleanedvalues`` overloads:

    * ``holidays_map`` is given → use that map; entries where the map is
      ``False`` are dropped.
    * ``holidays_map is None`` and ``skip_all_nans=True`` → drop NaN entries.
      For an :class:`~tsecon.mvtseries.MVTSeries`, only rows where *all*
      columns are NaN are dropped; rows where some columns are NaN and some
      are not emit a :class:`UserWarning` and are also dropped.
    * ``holidays_map is None`` and ``skip_holidays=True`` → fetch the global
      map from ``getoption("bdaily_holidays_map")``; raise if missing.
    * No kwargs → return the underlying values unchanged.

    The 1-D return is ``t.values`` for a :class:`~tsecon.tseries.TSeries`; the
    2-D return is ``mvts.values`` (an ``(rows, cols)`` view) for an
    :class:`~tsecon.mvtseries.MVTSeries`.
    """
    _require_bdaily(t)
    if holidays_map is not None:
        return bdvalues(t, holidays_map=holidays_map)
    if skip_all_nans:
        return _drop_all_nan_rows(t)
    if skip_holidays:
        h_map = getoption("bdaily_holidays_map")
        if not isinstance(h_map, TSeries) or not isinstance(h_map.frequency, BDaily):
            msg = (
                "skip_holidays=True requires a BDaily TSeries stored under "
                "getoption('bdaily_holidays_map'); none is currently set. "
                "Use tsecon.set_holidays_map(...) to install one."
            )
            raise ValueError(msg)
        return bdvalues(t, holidays_map=h_map)
    return np.asarray(t.values)

apct

apct(t: TSeries, islog: bool = False) -> TSeries

Annualised percent rate of change.

Requires a :class:~tsecon.frequencies.YPFrequency (Yearly / HalfYearly / Quarterly / Monthly). The annualisation exponent is ppy(t.frequency).

Source code in src/tsecon/_math.py
def apct(t: TSeries, islog: bool = False) -> TSeries:
    """Annualised percent rate of change.

    Requires a :class:`~tsecon.frequencies.YPFrequency` (Yearly / HalfYearly /
    Quarterly / Monthly). The annualisation exponent is ``ppy(t.frequency)``.
    """
    if not isinstance(t.frequency, YPFrequency):
        msg = f"apct is only defined for YPFrequency series; got {type(t.frequency).__name__}."
        raise TypeError(msg)
    n = ppy(t.frequency)
    if islog:
        exp_t = TSeries(t.firstdate, np.exp(t.values))
        a = exp_t
        b = shift(exp_t, -1)
    else:
        a = t
        b = shift(t, -1)
    return _as_tseries(((a / b) ** n - 1) * 100, reference=t)

diff

diff(
    t: TSeries | MVTSeries,
    k: int = -1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

First (or k-th) difference of t.

Defined as t - shift(t, k). With the default k=-1 (subtract the lag), this matches the standard first-difference operator.

For an :class:~tsecon.mvtseries.MVTSeries the operation is performed column-wise; the result is a new MVTSeries one row shorter (for |k|=1). BDaily kwargs are forwarded to the inner lag call so NaN/holiday infill is applied to the lag side of the subtraction (mirrors Base.diff(::TSeries{BDaily}) in tseries.jl).

Source code in src/tsecon/_math.py
def diff(
    t: TSeries | MVTSeries,
    k: int = -1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """First (or ``k``-th) difference of ``t``.

    Defined as ``t - shift(t, k)``. With the default ``k=-1`` (subtract the
    lag), this matches the standard first-difference operator.

    For an :class:`~tsecon.mvtseries.MVTSeries` the operation is performed
    column-wise; the result is a new MVTSeries one row shorter (for
    ``|k|=1``). BDaily kwargs are forwarded to the inner ``lag`` call so
    NaN/holiday infill is applied to the lag side of the subtraction
    (mirrors ``Base.diff(::TSeries{BDaily})`` in ``tseries.jl``).
    """
    _check_bdaily_kwargs(t, skip_all_nans, skip_holidays, holidays_map)
    if isinstance(t, MVTSeries):
        if not _has_bdaily_kwargs(skip_all_nans, skip_holidays, holidays_map):
            return _diff_mvts(t, int(k))
        # BDaily kwargs path: build the lag (with infill) and subtract via MVTSeries arithmetic.
        lag_t = cast(
            "MVTSeries",
            lag(
                t,
                -int(k),
                skip_all_nans=skip_all_nans,
                skip_holidays=skip_holidays,
                holidays_map=holidays_map,
            ),
        )
        return cast("MVTSeries", t - lag_t)
    lag_ts = lag(
        t,
        -int(k),
        skip_all_nans=skip_all_nans,
        skip_holidays=skip_holidays,
        holidays_map=holidays_map,
    )
    return _as_tseries(t - lag_ts, reference=t)

lag

lag(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

Return the k-th lag. Same as shift(t, -k).

Source code in src/tsecon/_math.py
def lag(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """Return the ``k``-th lag. Same as ``shift(t, -k)``."""
    return shift(
        t,
        -int(k),
        skip_all_nans=skip_all_nans,
        skip_holidays=skip_holidays,
        holidays_map=holidays_map,
    )

lag_inplace

lag_inplace(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

In-place version of :func:lag.

Source code in src/tsecon/_math.py
def lag_inplace(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """In-place version of :func:`lag`."""
    return shift_inplace(
        t,
        -int(k),
        skip_all_nans=skip_all_nans,
        skip_holidays=skip_holidays,
        holidays_map=holidays_map,
    )

lead

lead(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

Return the k-th lead. Same as shift(t, k).

Source code in src/tsecon/_math.py
def lead(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """Return the ``k``-th lead. Same as ``shift(t, k)``."""
    return shift(
        t,
        int(k),
        skip_all_nans=skip_all_nans,
        skip_holidays=skip_holidays,
        holidays_map=holidays_map,
    )

lead_inplace

lead_inplace(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

In-place version of :func:lead.

Source code in src/tsecon/_math.py
def lead_inplace(
    t: TSeries | MVTSeries,
    k: int = 1,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """In-place version of :func:`lead`."""
    return shift_inplace(
        t,
        int(k),
        skip_all_nans=skip_all_nans,
        skip_holidays=skip_holidays,
        holidays_map=holidays_map,
    )

moving

moving(
    t: TSeries | MVTSeries, n: int
) -> TSeries | MVTSeries

Compute the moving average of t over a window of n periods.

If n > 0 the window is backward-looking (-n+1 .. 0) (the result at period p is the mean of t[p-n+1 .. p]). If n < 0 the window is forward-looking (0 .. -n-1) (the result at p is the mean of t[p .. p+|n|-1]).

The returned series has length len(t) - |n| + 1. For n > 0 its firstdate is t.firstdate + n - 1; for n < 0 it is t.firstdate.

Identical to :func:moving_average; the bare name matches the Julia convention. Accepts a :class:~tsecon.tseries.TSeries or :class:~tsecon.mvtseries.MVTSeries.

Source code in src/tsecon/_math.py
def moving(t: TSeries | MVTSeries, n: int) -> TSeries | MVTSeries:
    """Compute the moving average of ``t`` over a window of ``n`` periods.

    If ``n > 0`` the window is backward-looking ``(-n+1 .. 0)`` (the result at
    period ``p`` is the mean of ``t[p-n+1 .. p]``). If ``n < 0`` the window is
    forward-looking ``(0 .. -n-1)`` (the result at ``p`` is the mean of
    ``t[p .. p+|n|-1]``).

    The returned series has length ``len(t) - |n| + 1``. For ``n > 0`` its
    ``firstdate`` is ``t.firstdate + n - 1``; for ``n < 0`` it is
    ``t.firstdate``.

    Identical to :func:`moving_average`; the bare name matches the Julia
    convention. Accepts a :class:`~tsecon.tseries.TSeries` or
    :class:`~tsecon.mvtseries.MVTSeries`.
    """
    return moving_average(t, n)

moving_average

moving_average(
    t: TSeries | MVTSeries, n: int
) -> TSeries | MVTSeries

Compute the moving average of t over a window of n periods. See :func:moving.

Source code in src/tsecon/_math.py
def moving_average(t: TSeries | MVTSeries, n: int) -> TSeries | MVTSeries:
    """Compute the moving average of ``t`` over a window of ``n`` periods. See :func:`moving`."""
    if isinstance(t, MVTSeries):
        return _moving_sum_mvts(t, int(n), avg=True)
    return _moving_sum(t, int(n), avg=True)

moving_sum

moving_sum(
    t: TSeries | MVTSeries, n: int
) -> TSeries | MVTSeries

Compute the rolling sum of t over a window of n periods.

Identical to :func:moving_average but without dividing by |n|.

Source code in src/tsecon/_math.py
def moving_sum(t: TSeries | MVTSeries, n: int) -> TSeries | MVTSeries:
    """Compute the rolling sum of ``t`` over a window of ``n`` periods.

    Identical to :func:`moving_average` but without dividing by ``|n|``.
    """
    if isinstance(t, MVTSeries):
        return _moving_sum_mvts(t, int(n), avg=False)
    return _moving_sum(t, int(n), avg=False)

pct

pct(
    t: TSeries | MVTSeries,
    shift_value: int = -1,
    *,
    islog: bool = False,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

Observation-to-observation percent rate of change.

When islog is True, t is interpreted as a log-series — values are exponentiated before differencing.

For BDaily series, accepts skip_all_nans / skip_holidays / holidays_map kwargs. The kwargs are forwarded to the inner :func:shift call (the value that ends up in the denominator), so NaN / holiday infill on the shift result propagates into the resulting percent series.

Source code in src/tsecon/_math.py
def pct(
    t: TSeries | MVTSeries,
    shift_value: int = -1,
    *,
    islog: bool = False,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """Observation-to-observation percent rate of change.

    When ``islog`` is ``True``, ``t`` is interpreted as a log-series — values
    are exponentiated before differencing.

    For BDaily series, accepts ``skip_all_nans`` / ``skip_holidays`` /
    ``holidays_map`` kwargs. The kwargs are forwarded to the inner
    :func:`shift` call (the value that ends up in the denominator), so NaN
    / holiday infill on the shift result propagates into the resulting
    percent series.
    """
    _check_bdaily_kwargs(t, skip_all_nans, skip_holidays, holidays_map)
    if islog:
        if isinstance(t, MVTSeries):
            exp_arr = np.exp(np.asarray(t.values, dtype=np.float64))
            a: TSeries | MVTSeries = MVTSeries(t.firstdate, list(t._columns.keys()), exp_arr)
        else:
            a = TSeries(t.firstdate, np.exp(np.asarray(t.values, dtype=np.float64)))
        b = shift(
            a,
            int(shift_value),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    else:
        a = t
        b = shift(
            t,
            int(shift_value),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if isinstance(t, MVTSeries):
        return cast("MVTSeries", ((a - b) / b) * 100)
    return _as_tseries(((a - b) / b) * 100, reference=t)

shift

shift(
    t: TSeries | MVTSeries,
    k: int,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

Return a copy of t with its dates shifted by k periods.

By convention, positive k produces the lead (the value at t+k is read at t); negative k produces the lag. The returned series has the same values but firstdate moved by -k periods.

Accepts a :class:~tsecon.tseries.TSeries or :class:~tsecon.mvtseries.MVTSeries.

BDaily kwargs (skip_all_nans / skip_holidays / holidays_map) are accepted only when t.frequency is :class:BDaily; on the shifted result they run :func:tsecon._bdaily.replace_nans_if_warranted to infill NaN entries. For an MVTSeries{BDaily} the infill is applied per-column (this extends Julia's TSeries-only overload — Julia's MVTSeries shift has no kwarg path, so a Python user passing kwargs would otherwise just lose them).

Source code in src/tsecon/_math.py
def shift(
    t: TSeries | MVTSeries,
    k: int,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """Return a copy of ``t`` with its dates shifted by ``k`` periods.

    By convention, positive ``k`` produces the *lead* (the value at ``t+k`` is
    read at ``t``); negative ``k`` produces the *lag*. The returned series has
    the same values but ``firstdate`` moved by ``-k`` periods.

    Accepts a :class:`~tsecon.tseries.TSeries` or
    :class:`~tsecon.mvtseries.MVTSeries`.

    BDaily kwargs (``skip_all_nans`` / ``skip_holidays`` / ``holidays_map``)
    are accepted only when ``t.frequency`` is :class:`BDaily`; on the shifted
    result they run :func:`tsecon._bdaily.replace_nans_if_warranted` to
    infill NaN entries. For an MVTSeries{BDaily} the infill is applied
    per-column (this extends Julia's TSeries-only overload — Julia's
    MVTSeries shift has no kwarg path, so a Python user passing kwargs would
    otherwise just lose them).
    """
    _check_bdaily_kwargs(t, skip_all_nans, skip_holidays, holidays_map)
    if isinstance(t, MVTSeries):
        return _shift_mvts_copy(
            t,
            int(k),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    new_start = MIT(t.frequency, t.firstdate.value - int(k))
    new_values = t.values.copy()
    if _has_bdaily_kwargs(skip_all_nans, skip_holidays, holidays_map):
        if not np.issubdtype(new_values.dtype, np.floating):
            new_values = new_values.astype(np.float64)
        new_ts = TSeries(new_start, new_values)
        replace_nans_if_warranted(
            new_ts,
            int(k),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
        return new_ts
    return TSeries(new_start, new_values)

shift_inplace

shift_inplace(
    t: TSeries | MVTSeries,
    k: int,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries

In-place version of :func:shift. Mutates the firstdate(s) and returns the same object.

Source code in src/tsecon/_math.py
def shift_inplace(
    t: TSeries | MVTSeries,
    k: int,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> TSeries | MVTSeries:
    """In-place version of :func:`shift`. Mutates the firstdate(s) and returns the same object."""
    _check_bdaily_kwargs(t, skip_all_nans, skip_holidays, holidays_map)
    if isinstance(t, MVTSeries):
        new_start = MIT(t.frequency, t.firstdate.value - int(k))
        t._firstdate = new_start
        for col in t._columns.values():
            col._firstdate = MIT(col.frequency, col.firstdate.value - int(k))
        if _has_bdaily_kwargs(skip_all_nans, skip_holidays, holidays_map):
            _bdaily_infill_mvts_inplace(
                t,
                int(k),
                skip_all_nans=skip_all_nans,
                skip_holidays=skip_holidays,
                holidays_map=holidays_map,
            )
        return t
    t._firstdate = MIT(t.frequency, t.firstdate.value - int(k))
    if _has_bdaily_kwargs(skip_all_nans, skip_holidays, holidays_map):
        if not np.issubdtype(t.values.dtype, np.floating):
            msg = (
                "shift_inplace with BDaily kwargs requires a float-dtype series; "
                f"got dtype {t.values.dtype}. Use shift(...) for a fresh allocation."
            )
            raise TypeError(msg)
        replace_nans_if_warranted(
            t,
            int(k),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    return t

undiff

undiff(
    dvar: TSeries | MVTSeries,
    anchor: _Anchor | _MVAnchor = 0,
) -> TSeries | MVTSeries

Inverse of :func:diff (cumulative sum, anchored at a known value).

dvar is the differenced series. anchor says what the integrated series equals at some anchor date; the result is the cumulative sum of dvar shifted so that result[date] == value. Forms accepted:

  • anchor=v (a number, default 0) — anchor date defaults to firstdate(dvar) - 1 (the period just before the differencing window). If that date is outside dvar.range, dvar is extended with zeros so that the anchor falls inside.
  • anchor=(date, value) — both date and value given explicitly.
  • anchor=other_tseries — anchor date defaults to firstdate(dvar)-1; the value is read from other_tseries at that date.
  • anchor=(date, other_tseries) — value is read from other_tseries[date].
Forward vs. backward integration

The math is direction-agnostic: undiff computes a cumulative sum and chooses the constant of integration so result[anchor_date] == anchor_value. The anchor's position determines the user-visible direction:

  • Forward — anchor at or before firstdate(dvar) - 1 (the default anchor=v form). Values past the anchor are accumulated forward.
  • Backward (backcasting) — anchor at lastdate(dvar) or later, e.g. undiff(dvar, (dvar.lastdate, terminal_value)). Values before the anchor are recovered by walking the same cumulative-sum curve backward; result spans dvar.range.

An anchor strictly after lastdate(dvar) extends dvar with zeros so the anchor falls inside the new range — the tail (positions strictly between lastdate(dvar) and the anchor) is flat at the anchor value.

Note on a mid-range anchor: when date falls inside dvar.range, the cumulative sum is still computed over the full range; the result is then shifted by a constant so result[date] == value — every other period moves by the same constant. See undiff_inplace for the alternative semantics where dvar values at and before fromdate are ignored.

On an :class:~tsecon.mvtseries.MVTSeries, anchor may additionally be a vector / matrix (one entry per column) or another MVTSeries; the cumulative sum is performed column-wise.

Dispatch

The TSeries path's hot loop (cumsum over the integrated chunk + constant-shift correction) routes through the :func:tsecon._math_kernels.cumsum_anchored_numpy / :func:tsecon._math_kernels_cy.cumsum_anchored_cython kernel pair when the integrated buffer is float64 + C-contiguous; use :func:undiff_is_cython to check which path is active. Integer dvar + integer anchor combinations (which would broaden the result dtype away from float64) stay on the pure-NumPy path so the integer return type is preserved. The MVTSeries path stays pure NumPy in M1.6 — np.cumsum(..., axis=0) already runs the column-wise reduction in C, and the outer benchmark ratio sits at 14× rather than the 25-80× band the kernel addresses.

Source code in src/tsecon/_math.py
def undiff(
    dvar: TSeries | MVTSeries,
    anchor: _Anchor | _MVAnchor = 0,
) -> TSeries | MVTSeries:
    """Inverse of :func:`diff` (cumulative sum, anchored at a known value).

    ``dvar`` is the differenced series. ``anchor`` says what the *integrated*
    series equals at some anchor date; the result is the cumulative sum of
    ``dvar`` shifted so that ``result[date] == value``. Forms accepted:

    * ``anchor=v`` (a number, default ``0``) — anchor date defaults to
      ``firstdate(dvar) - 1`` (the period just before the differencing window).
      If that date is outside ``dvar.range``, ``dvar`` is extended with zeros
      so that the anchor falls inside.
    * ``anchor=(date, value)`` — both date and value given explicitly.
    * ``anchor=other_tseries`` — anchor date defaults to ``firstdate(dvar)-1``;
      the value is read from ``other_tseries`` at that date.
    * ``anchor=(date, other_tseries)`` — value is read from
      ``other_tseries[date]``.

    Forward vs. backward integration
    --------------------------------
    The math is direction-agnostic: ``undiff`` computes a cumulative sum and
    chooses the constant of integration so ``result[anchor_date] == anchor_value``.
    The anchor's position determines the user-visible direction:

    * **Forward** — anchor at or before ``firstdate(dvar) - 1`` (the default
      ``anchor=v`` form). Values past the anchor are accumulated forward.
    * **Backward (backcasting)** — anchor at ``lastdate(dvar)`` or later,
      e.g. ``undiff(dvar, (dvar.lastdate, terminal_value))``. Values
      before the anchor are recovered by walking the same cumulative-sum
      curve backward; result spans ``dvar.range``.

    An anchor strictly *after* ``lastdate(dvar)`` extends ``dvar`` with zeros
    so the anchor falls inside the new range — the tail (positions strictly
    between ``lastdate(dvar)`` and the anchor) is flat at the anchor value.

    Note on a mid-range anchor: when ``date`` falls inside ``dvar.range``, the
    cumulative sum is still computed over the full range; the result is then
    shifted by a constant so ``result[date] == value`` — every other period
    moves by the same constant. See ``undiff_inplace`` for the alternative
    semantics where ``dvar`` values at and before ``fromdate`` are ignored.

    On an :class:`~tsecon.mvtseries.MVTSeries`, ``anchor`` may additionally
    be a vector / matrix (one entry per column) or another MVTSeries; the
    cumulative sum is performed column-wise.

    Dispatch
    --------
    The TSeries path's hot loop (cumsum over the integrated chunk +
    constant-shift correction) routes through the
    :func:`tsecon._math_kernels.cumsum_anchored_numpy` /
    :func:`tsecon._math_kernels_cy.cumsum_anchored_cython` kernel pair
    when the integrated buffer is ``float64`` + C-contiguous; use
    :func:`undiff_is_cython` to check which path is active. Integer
    ``dvar`` + integer anchor combinations (which would broaden the
    result dtype away from ``float64``) stay on the pure-NumPy path
    so the integer return type is preserved. The MVTSeries path stays
    pure NumPy in M1.6 — ``np.cumsum(..., axis=0)`` already runs the
    column-wise reduction in C, and the outer benchmark ratio sits at
    14× rather than the 25-80× band the kernel addresses.
    """
    if isinstance(dvar, MVTSeries):
        return _undiff_mvts(dvar, anchor)
    ad, av = _resolve_undiff_anchor(dvar, anchor)
    # Promote dtype so that an integer dvar + float anchor produces a float
    # series, matching Julia's `Base.promote_eltype(dvar, value)`.
    av_arr = np.asarray(av)
    et = np.result_type(dvar.values.dtype, av_arr.dtype)
    rng = dvar.range
    if not (rng.start <= ad <= rng.stop):
        # Extend dvar with zeros so the anchor period is inside.
        new_range = rangeof_span(MITRange(ad, ad), rng)
        extended = TSeries(new_range, 0, dtype=et)
        if not rng.is_empty():
            extended[rng] = dvar
        dvar = extended
        rng = new_range
    ad_idx = ad.value - rng.start.value
    # Copy dvar values into a fresh float64 buffer; the kernel mutates it
    # in place (cumsum + correction). For non-float64 result dtypes (e.g.
    # int dvar + int anchor) stay on the NumPy path so the integer return
    # type is preserved.
    if et == np.float64:
        result_arr = dvar.values.astype(np.float64, copy=True)
        if _is_kernel_eligible(result_arr):
            _dispatch_kernel(
                _CYTHON_AVAILABLE,
                cumsum_anchored_cython,
                cumsum_anchored_numpy,
                result_arr,
                0,
                result_arr.shape[0],
                float(av),
                ad_idx,
            )
            return TSeries(rng.start, result_arr)
    # Non-float64 fallback (preserves integer return dtype):
    result_arr = np.cumsum(dvar.values).astype(et, copy=True)
    correction = av - result_arr[ad_idx]
    return TSeries(rng.start, result_arr + correction)

undiff_inplace

undiff_inplace(
    var: TSeries,
    dvar: TSeries,
    *,
    fromdate: MIT | None = None,
) -> TSeries

Anchor-based undiff that writes into var (mirrors Julia undiff!).

Reads the anchor value from var[fromdate] and writes the integrated series into var at fromdate+1 .. lastdate(dvar). Values of var at and before fromdate are left untouched, and values of dvar at and before fromdate are ignored (treated as zero).

If var does not yet extend to lastdate(dvar), it is resized (extending the end). fromdate defaults to firstdate(dvar) - 1 and must satisfy fromdate >= firstdate(var).

.. note::

The semantics here differ from :func:undiff when fromdate falls in the middle of rangeof(dvar). There, :func:undiff shifts the whole cumulative result so result[fromdate] == value; :func:undiff_inplace zeros out the earlier dvar entries instead, so the integrated tail starts cleanly from the existing anchor.

Dispatch

Shares the cumsum + anchor-shift kernel with :func:undiff — the inplace variant calls into the same :func:tsecon._math_kernels.cumsum_anchored_numpy / :func:tsecon._math_kernels_cy.cumsum_anchored_cython pair with anchor_relative_idx = -1 (the anchor sits one position before the integrated chunk, so the correction collapses to a single fused add per element). Use :func:undiff_is_cython to check which path is active. The kernel only engages when var.values is float64 + C-contiguous; other dtypes fall back to the pre-existing np.cumsum path.

Source code in src/tsecon/_math.py
def undiff_inplace(
    var: TSeries,
    dvar: TSeries,
    *,
    fromdate: MIT | None = None,
) -> TSeries:
    """Anchor-based undiff that writes into ``var`` (mirrors Julia ``undiff!``).

    Reads the anchor value from ``var[fromdate]`` and writes the integrated
    series into ``var`` at ``fromdate+1 .. lastdate(dvar)``. Values of ``var``
    at and before ``fromdate`` are left untouched, and values of ``dvar`` at
    and before ``fromdate`` are ignored (treated as zero).

    If ``var`` does not yet extend to ``lastdate(dvar)``, it is resized
    (extending the end). ``fromdate`` defaults to ``firstdate(dvar) - 1`` and
    must satisfy ``fromdate >= firstdate(var)``.

    .. note::

       The semantics here differ from :func:`undiff` when ``fromdate`` falls
       in the middle of ``rangeof(dvar)``. There, :func:`undiff` shifts the
       *whole* cumulative result so ``result[fromdate] == value``;
       :func:`undiff_inplace` zeros out the earlier ``dvar`` entries instead,
       so the integrated tail starts cleanly from the existing anchor.

    Dispatch
    --------
    Shares the cumsum + anchor-shift kernel with :func:`undiff` — the
    inplace variant calls into the same
    :func:`tsecon._math_kernels.cumsum_anchored_numpy` /
    :func:`tsecon._math_kernels_cy.cumsum_anchored_cython` pair with
    ``anchor_relative_idx = -1`` (the anchor sits one position before
    the integrated chunk, so the correction collapses to a single
    fused add per element). Use :func:`undiff_is_cython` to check
    which path is active. The kernel only engages when ``var.values``
    is ``float64`` + C-contiguous; other dtypes fall back to the
    pre-existing ``np.cumsum`` path.
    """
    if var.frequency != dvar.frequency:
        raise _mixed_freq(var.frequency, dvar.frequency)
    fd = MIT(dvar.frequency, dvar.firstdate.value - 1) if fromdate is None else fromdate
    if fd.frequency != dvar.frequency:
        raise _mixed_freq(fd.frequency, dvar.frequency)
    if fd < var.firstdate:
        msg = f"Range mismatch: fromdate {fd!s} < firstdate(var) {var.firstdate!s}."
        raise ValueError(msg)
    if var.lastdate < dvar.lastdate:
        var.resize(MITRange(var.firstdate, dvar.lastdate))
    if fd >= dvar.lastdate:
        return var
    # Compute var[fd+1 .. lastdate(dvar)] = var[fd] + cumsum(dvar[fd+1 .. lastdate]).
    start_val = fd.value + 1
    stop_val = dvar.lastdate.value
    n = stop_val - start_val + 1
    dvar_off = start_val - dvar.firstdate.value
    if dvar_off < 0:
        msg = (
            f"fromdate {fd!s} is too far before firstdate(dvar) "
            f"{dvar.firstdate!s}; dvar lookups would be out of range."
        )
        raise ValueError(msg)
    var_anchor_idx = fd.value - var.firstdate.value
    var_write_start = var_anchor_idx + 1
    if (
        var.values.dtype == np.float64
        and dvar.values.dtype == np.float64
        and _is_kernel_eligible(var.values)
    ):
        # Copy the dvar chunk into the destination slot, then run the
        # in-place cumsum kernel anchored at the position just before
        # the chunk (anchor_relative_idx = -1, reference cumsum = 0).
        var.values[var_write_start : var_write_start + n] = dvar.values[dvar_off : dvar_off + n]
        _dispatch_kernel(
            _CYTHON_AVAILABLE,
            cumsum_anchored_cython,
            cumsum_anchored_numpy,
            var.values,
            var_write_start,
            n,
            float(var.values[var_anchor_idx]),
            -1,
        )
        return var
    # Non-float64 fallback (preserves the original dtype-promoting cumsum path):
    dvar_chunk = dvar.values[dvar_off : dvar_off + n]
    var.values[var_write_start : var_write_start + n] = var.values[var_anchor_idx] + np.cumsum(
        dvar_chunk
    )
    return var

undiff_is_cython

undiff_is_cython() -> bool

Return True iff the Cython-compiled cumsum-anchored kernel was importable.

Useful for tests, benchmarks, and diagnostic prints — the public :func:undiff and :func:undiff_inplace are implementation- agnostic. When this returns False the same calls go through the pure-NumPy kernel in _math_kernels.py; behaviour is identical, only speed differs.

The fast path also requires the integrated buffer to be float64 + C-contiguous (the canonical kernel-eligibility contract); when either condition fails (e.g. integer dvar with integer anchor), the call routes through np.cumsum even when the extension is importable.

Source code in src/tsecon/_math.py
def undiff_is_cython() -> bool:
    """Return True iff the Cython-compiled cumsum-anchored kernel was importable.

    Useful for tests, benchmarks, and diagnostic prints — the public
    :func:`undiff` and :func:`undiff_inplace` are implementation-
    agnostic. When this returns ``False`` the same calls go through
    the pure-NumPy kernel in ``_math_kernels.py``; behaviour is
    identical, only speed differs.

    The fast path also requires the integrated buffer to be ``float64``
    + C-contiguous (the canonical kernel-eligibility contract); when
    either condition fails (e.g. integer ``dvar`` with integer
    anchor), the call routes through ``np.cumsum`` even when the
    extension is importable.
    """
    return _CYTHON_AVAILABLE

ytypct

ytypct(t: TSeries) -> TSeries

Year-to-year percent change.

Requires a :class:~tsecon.frequencies.YPFrequency. Implemented as 100 * (t / shift(t, -ppy(t.frequency)) - 1).

Source code in src/tsecon/_math.py
def ytypct(t: TSeries) -> TSeries:
    """Year-to-year percent change.

    Requires a :class:`~tsecon.frequencies.YPFrequency`. Implemented as
    ``100 * (t / shift(t, -ppy(t.frequency)) - 1)``.
    """
    if not isinstance(t.frequency, YPFrequency):
        msg = f"ytypct is only defined for YPFrequency series; got {type(t.frequency).__name__}."
        raise TypeError(msg)
    return _as_tseries(100 * (t / shift(t, -ppy(t.frequency)) - 1), reference=t)

clear_holidays_map

clear_holidays_map() -> None

Reset bdaily_holidays_map to None.

Source code in src/tsecon/_options.py
def clear_holidays_map() -> None:
    """Reset ``bdaily_holidays_map`` to ``None``."""
    setoption("bdaily_holidays_map", None)

get_holidays_map

get_holidays_map() -> Any

Return the currently installed holidays map (or None).

Source code in src/tsecon/_options.py
def get_holidays_map() -> Any:
    """Return the currently installed holidays map (or ``None``)."""
    return _options["bdaily_holidays_map"]

get_holidays_options

get_holidays_options(
    country: str | None = None,
) -> tuple[str, ...]

List supported country codes (or subdivisions of a given country).

Mirrors Julia's get_holidays_options(country=nothing). Both no-arg and country-arg forms return a sorted tuple. Subdivisions are reported using the country's native code (ISO 3166-2 short form), exactly as the holidays package exposes them.

Parameters:

Name Type Description Default
country str | None

Optional country code accepted by :func:holidays.country_holidays. When None, return all supported country codes.

None

Returns:

Type Description
tuple of str

Sorted country codes (no arg) or sorted subdivision codes (one arg). A country with no subdivisions returns an empty tuple.

Raises:

Type Description
ImportError

The holidays package is not installed.

ValueError

country is not a supported country code. The Julia upstream raises :class:ArgumentError.

Source code in src/tsecon/_options.py
def get_holidays_options(country: str | None = None) -> tuple[str, ...]:
    """List supported country codes (or subdivisions of a given country).

    Mirrors Julia's ``get_holidays_options(country=nothing)``. Both no-arg
    and country-arg forms return a sorted tuple. Subdivisions are reported
    using the country's native code (ISO 3166-2 short form), exactly as
    the ``holidays`` package exposes them.

    Parameters
    ----------
    country
        Optional country code accepted by
        :func:`holidays.country_holidays`. When ``None``, return all
        supported country codes.

    Returns
    -------
    tuple of str
        Sorted country codes (no arg) or sorted subdivision codes (one
        arg). A country with no subdivisions returns an empty tuple.

    Raises
    ------
    ImportError
        The ``holidays`` package is not installed.
    ValueError
        ``country`` is not a supported country code. The Julia upstream
        raises :class:`ArgumentError`.
    """
    holidays_mod = _require_holidays()
    countries = holidays_mod.list_supported_countries()
    if country is None:
        return tuple(sorted(countries))
    if country not in countries:
        msg = (
            f"Unsupported country: {country!r}. "
            "Call get_holidays_options() (no argument) to list supported codes."
        )
        raise ValueError(msg)
    return tuple(sorted(countries[country]))

getoption

getoption(name: OptionName | str) -> Any

Return the current value of a recognised option.

Raises :class:KeyError for unknown names.

Source code in src/tsecon/_options.py
def getoption(name: OptionName | str) -> Any:
    """Return the current value of a recognised option.

    Raises :class:`KeyError` for unknown names.
    """
    if name not in _DEFAULTS:
        msg = f"unknown option: {name!r}. Recognised names: {sorted(_DEFAULTS)}."
        raise KeyError(msg)
    return _options[name]

option_scope

option_scope(**overrides: Any) -> Iterator[None]

Context manager that temporarily overrides one or more options.

On exit, every overridden option is restored to its prior value. Convenient for tests; the wrapper goes through :func:setoption so the same validation applies.

Source code in src/tsecon/_options.py
@contextmanager
def option_scope(**overrides: Any) -> Iterator[None]:
    """Context manager that temporarily overrides one or more options.

    On exit, every overridden option is restored to its prior value.
    Convenient for tests; the wrapper goes through :func:`setoption` so the
    same validation applies.
    """
    saved: dict[str, Any] = {name: _options[name] for name in overrides}
    try:
        for name, value in overrides.items():
            setoption(name, value)
        yield
    finally:
        for name, value in saved.items():
            _options[name] = value

set_holidays_map

set_holidays_map(
    arg: Any, subdivision: str | None = None
) -> None

Install a BDaily Boolean holidays map under bdaily_holidays_map.

Two call forms:

  • set_holidays_map(t) — install a pre-built BDaily Boolean :class:~tsecon.tseries.TSeries (True = business day, False = holiday).
  • set_holidays_map("CA", "ON") — fetch the calendar for the given country / subdivision from the holidays PyPI package, build a BDaily Boolean TSeries spanning bdaily("1970-01-01") to bdaily("2049-12-31") (matches the Julia upstream's default range), and install it.

Parameters:

Name Type Description Default
arg Any

Either a pre-built BDaily Boolean :class:~tsecon.tseries.TSeries or a country code string accepted by :func:holidays.country_holidays (ISO 3166 alpha-2 like "CA", "US", "DK"; alpha-3 codes like "CAN" are also accepted — whatever the upstream package supports).

required
subdivision str | None

Optional subdivision code (ISO 3166-2 short form, e.g. "ON", "QC", "CA"). Only meaningful when arg is a country code; passing it with a TSeries raises :class:TypeError.

None

Raises:

Type Description
ImportError

arg is a string and the holidays package is not installed. The error message includes the pip install hint.

ValueError

arg is an unsupported country code, or subdivision is not a recognised subdivision of arg. The Julia upstream raises :class:ArgumentError here.

TypeError

subdivision is passed alongside a TSeries, or arg is neither a string nor a TSeries.

Notes

The country / subdivision codes follow the python-holidays package's conventions (ISO 3166), which differ in minor cases from the Julia upstream's CSV-derived names (e.g. the Julia CSVs spell a few subdivisions with spaces that the package replaces with underscores or drops). The package is the single source of truth; discover the supported codes with :func:get_holidays_options.

Examples:

>>> set_holidays_map("DK")  # Denmark, federal holidays
>>> set_holidays_map("CA", "ON")  # Ontario, Canada
>>> from tsecon import bdaily, TSeries, MITRange
>>> cal = TSeries.trues(MITRange(bdaily("2022-01-03"), bdaily("2022-12-30")))
>>> set_holidays_map(cal)  # pre-built TSeries
Source code in src/tsecon/_options.py
def set_holidays_map(arg: Any, subdivision: str | None = None) -> None:
    """Install a BDaily Boolean holidays map under ``bdaily_holidays_map``.

    Two call forms:

    * ``set_holidays_map(t)`` — install a pre-built BDaily Boolean
      :class:`~tsecon.tseries.TSeries` (``True`` = business day,
      ``False`` = holiday).
    * ``set_holidays_map("CA", "ON")`` — fetch the calendar for the given
      country / subdivision from the ``holidays`` PyPI package, build a
      BDaily Boolean TSeries spanning ``bdaily("1970-01-01")`` to
      ``bdaily("2049-12-31")`` (matches the Julia upstream's default
      range), and install it.

    Parameters
    ----------
    arg
        Either a pre-built BDaily Boolean :class:`~tsecon.tseries.TSeries`
        or a country code string accepted by
        :func:`holidays.country_holidays` (ISO 3166 alpha-2 like ``"CA"``,
        ``"US"``, ``"DK"``; alpha-3 codes like ``"CAN"`` are also accepted
        — whatever the upstream package supports).
    subdivision
        Optional subdivision code (ISO 3166-2 short form, e.g. ``"ON"``,
        ``"QC"``, ``"CA"``). Only meaningful when ``arg`` is a country
        code; passing it with a TSeries raises :class:`TypeError`.

    Raises
    ------
    ImportError
        ``arg`` is a string and the ``holidays`` package is not installed.
        The error message includes the ``pip install`` hint.
    ValueError
        ``arg`` is an unsupported country code, or ``subdivision`` is not
        a recognised subdivision of ``arg``. The Julia upstream raises
        :class:`ArgumentError` here.
    TypeError
        ``subdivision`` is passed alongside a TSeries, or ``arg`` is
        neither a string nor a TSeries.

    Notes
    -----
    The country / subdivision codes follow the ``python-holidays``
    package's conventions (ISO 3166), which differ in minor cases from
    the Julia upstream's CSV-derived names (e.g. the Julia CSVs spell
    a few subdivisions with spaces that the package replaces with
    underscores or drops). The package is the single source of truth;
    discover the supported codes with :func:`get_holidays_options`.

    Examples
    --------
    >>> set_holidays_map("DK")  # Denmark, federal holidays  # doctest: +SKIP
    >>> set_holidays_map("CA", "ON")  # Ontario, Canada  # doctest: +SKIP
    >>> from tsecon import bdaily, TSeries, MITRange  # doctest: +SKIP
    >>> cal = TSeries.trues(MITRange(bdaily("2022-01-03"), bdaily("2022-12-30")))
    >>> set_holidays_map(cal)  # pre-built TSeries  # doctest: +SKIP
    """
    if isinstance(arg, str):
        _set_holidays_map_from_country(arg, subdivision)
        return
    if subdivision is not None:
        msg = (
            "set_holidays_map: `subdivision=` is only meaningful when the first "
            f"argument is a country-code string; received {type(arg).__name__}."
        )
        raise TypeError(msg)
    setoption("bdaily_holidays_map", arg)

setoption

setoption(name: OptionName | str, value: Any) -> None

Set the value of a recognised option, validating its type.

  • bdaily_creation_bias — must be one of "strict", "previous", "next", "nearest".
  • bdaily_holidays_map — must be None or a Boolean BDaily :class:~tsecon.tseries.TSeries.
  • x13path — must be a string.
Source code in src/tsecon/_options.py
def setoption(name: OptionName | str, value: Any) -> None:
    """Set the value of a recognised option, validating its type.

    * ``bdaily_creation_bias`` — must be one of ``"strict"``, ``"previous"``,
      ``"next"``, ``"nearest"``.
    * ``bdaily_holidays_map`` — must be ``None`` or a Boolean BDaily
      :class:`~tsecon.tseries.TSeries`.
    * ``x13path`` — must be a string.
    """
    if name == "bdaily_creation_bias":
        if value not in VALID_BDAILY_BIASES:
            msg = (
                f"bdaily_creation_bias must be one of {sorted(VALID_BDAILY_BIASES)}; "
                f"received {value!r}."
            )
            raise ValueError(msg)
        _options[name] = value
        return
    if name == "bdaily_holidays_map":
        if value is not None:
            _validate_holidays_map(value)
        _options[name] = value
        return
    if name == "x13path":
        if not isinstance(value, str):
            msg = f"x13path must be a string; received {type(value).__name__}."
            raise TypeError(msg)
        _options[name] = value
        return
    msg = f"unknown option: {name!r}. Recognised names: {sorted(_DEFAULTS)}."
    raise KeyError(msg)

cor

cor(
    x: TSeries | MVTSeries,
    y: TSeries | None = None,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Pearson correlation.

Mirrors Julia's Statistics.cor overloads in tsmath.jl (lines 249-272 and 297). Two call shapes:

  • cor(t) — for a :class:~tsecon.tseries.TSeries this returns 1.0 (the trivial self-correlation). For an :class:~tsecon.mvtseries.MVTSeries it returns the column- correlation matrix (variables on rows = columns of the input).
  • cor(x, y) — two TSeries of the same frequency, firstdate and length; returns the scalar Pearson correlation between them.

Parameters:

Name Type Description Default
x TSeries or MVTSeries

First operand (or sole operand in the cor(t) / cor(mvts) forms).

required
y TSeries

Second operand. When given, both x and y must be TSeries with matching frequency, firstdate, and length.

None
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float or ndarray

Scalar in the single-TSeries or two-TSeries forms; an (n, n) correlation matrix in the MVTSeries form. Returns nan when fewer than two filtered values remain.

Raises:

Type Description
TypeError

cor(x, y) called with non-TSeries arguments.

ValueError

cor(x, y) arguments differ in frequency, firstdate, or length; or the BDaily filters produce mismatched lengths.

Notes

Correlation of a constant series is mathematically undefined (zero variance in the denominator). cor(x, y) returns nan and emits a RuntimeWarning when either input is bit-exactly constant, matching NumPy's np.corrcoef semantics on FP-exact constant input. The explicit guard is uniform across the NumPy and Cython kernels, so the return is nan for both np.full(N, 1.0) (where np.corrcoef itself would warn) and np.full(N, 1e-60) (where pairwise summation in np.corrcoef would otherwise silently return 1.0).

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> x = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
>>> y = tse.TSeries(tse.qq(2020, 1), np.array([2.0, 4.0, 6.0, 8.0]))
>>> float(tse.cor(x, y))
1.0
Source code in src/tsecon/_stats.py
def cor(
    x: TSeries | MVTSeries,
    y: TSeries | None = None,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Pearson correlation.

    Mirrors Julia's ``Statistics.cor`` overloads in ``tsmath.jl``
    (lines 249-272 and 297). Two call shapes:

    * ``cor(t)`` — for a :class:`~tsecon.tseries.TSeries` this returns
      ``1.0`` (the trivial self-correlation). For an
      :class:`~tsecon.mvtseries.MVTSeries` it returns the column-
      correlation matrix (variables on rows = columns of the input).
    * ``cor(x, y)`` — two TSeries of the same frequency, firstdate and
      length; returns the scalar Pearson correlation between them.

    Parameters
    ----------
    x : TSeries or MVTSeries
        First operand (or sole operand in the ``cor(t)`` / ``cor(mvts)``
        forms).
    y : TSeries, optional
        Second operand. When given, both ``x`` and ``y`` must be
        TSeries with matching frequency, firstdate, and length.
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float or ndarray
        Scalar in the single-TSeries or two-TSeries forms; an ``(n, n)``
        correlation matrix in the MVTSeries form. Returns ``nan`` when
        fewer than two filtered values remain.

    Raises
    ------
    TypeError
        ``cor(x, y)`` called with non-TSeries arguments.
    ValueError
        ``cor(x, y)`` arguments differ in frequency, firstdate, or
        length; or the BDaily filters produce mismatched lengths.

    Notes
    -----
    Correlation of a constant series is mathematically undefined (zero
    variance in the denominator). ``cor(x, y)`` returns ``nan`` and emits
    a ``RuntimeWarning`` when either input is bit-exactly constant, matching
    NumPy's ``np.corrcoef`` semantics on FP-exact constant input. The
    explicit guard is uniform across the NumPy and Cython kernels, so the
    return is ``nan`` for both ``np.full(N, 1.0)`` (where ``np.corrcoef``
    itself would warn) and ``np.full(N, 1e-60)`` (where pairwise summation
    in ``np.corrcoef`` would otherwise silently return ``1.0``).

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> x = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
    >>> y = tse.TSeries(tse.qq(2020, 1), np.array([2.0, 4.0, 6.0, 8.0]))
    >>> float(tse.cor(x, y))
    1.0
    """
    if y is None:
        if isinstance(x, MVTSeries):
            values = _resolve_values(
                x,
                skip_all_nans=skip_all_nans,
                skip_holidays=skip_holidays,
                holidays_map=holidays_map,
            )
            return np.corrcoef(values, rowvar=False)
        # cor(t) on a single TSeries — Julia returns 1.0 (vector self-correlation).
        return 1.0
    if not isinstance(x, TSeries) or not isinstance(y, TSeries):
        msg = (
            "cor(x, y) requires two TSeries arguments; got "
            f"{type(x).__name__!r} and {type(y).__name__!r}."
        )
        raise TypeError(msg)
    if x.frequency != y.frequency or x.firstdate != y.firstdate or len(x) != len(y):
        msg = (
            "cor(x, y) requires same-frequency same-firstdate same-length TSeries. "
            "Call cor(cleanedvalues(x), cleanedvalues(y)) directly to compare misaligned data."
        )
        raise ValueError(msg)
    xv = _resolve_values(
        x, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
    )
    yv = _resolve_values(
        y, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
    )
    if xv.shape[0] != yv.shape[0]:
        msg = (
            "cor(x, y): filtered arrays have unequal lengths "
            f"({xv.shape[0]} vs {yv.shape[0]}); supply matched holidays / NaN filters."
        )
        raise ValueError(msg)
    if xv.shape[0] < 2:
        return float("nan")
    if _is_kernel_eligible(xv, yv):
        return _dispatch_kernel(_CYTHON_AVAILABLE, cor_cython, cor_numpy, xv, yv)
    return float(np.corrcoef(xv, yv)[0, 1])

cov

cov(
    x: TSeries | MVTSeries,
    y: TSeries | None = None,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Sample covariance (ddof=1 to match Julia's Statistics.cov).

Same call shapes as :func:cor:

  • cov(t) for a TSeries collapses to :func:var.
  • cov(mvts) returns the column-covariance matrix.
  • cov(x, y) returns the scalar covariance between two TSeries of the same frequency, firstdate, and length.

Parameters:

Name Type Description Default
x TSeries or MVTSeries

First operand (or sole operand in the cov(t) / cov(mvts) forms).

required
y TSeries

Second operand. When given, both x and y must be TSeries with matching frequency, firstdate, and length.

None
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float or ndarray

Scalar in the TSeries forms; an (n, n) covariance matrix in the MVTSeries form.

Raises:

Type Description
TypeError

cov(x, y) called with non-TSeries arguments.

ValueError

cov(x, y) arguments differ in frequency, firstdate, or length; or the BDaily filters produce mismatched lengths.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> x = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
>>> y = tse.TSeries(tse.qq(2020, 1), np.array([2.0, 4.0, 6.0]))
>>> float(tse.cov(x, y))
2.0
Source code in src/tsecon/_stats.py
def cov(
    x: TSeries | MVTSeries,
    y: TSeries | None = None,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Sample covariance (``ddof=1`` to match Julia's ``Statistics.cov``).

    Same call shapes as :func:`cor`:

    * ``cov(t)`` for a TSeries collapses to :func:`var`.
    * ``cov(mvts)`` returns the column-covariance matrix.
    * ``cov(x, y)`` returns the scalar covariance between two TSeries
      of the same frequency, firstdate, and length.

    Parameters
    ----------
    x : TSeries or MVTSeries
        First operand (or sole operand in the ``cov(t)`` / ``cov(mvts)``
        forms).
    y : TSeries, optional
        Second operand. When given, both ``x`` and ``y`` must be
        TSeries with matching frequency, firstdate, and length.
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float or ndarray
        Scalar in the TSeries forms; an ``(n, n)`` covariance matrix
        in the MVTSeries form.

    Raises
    ------
    TypeError
        ``cov(x, y)`` called with non-TSeries arguments.
    ValueError
        ``cov(x, y)`` arguments differ in frequency, firstdate, or
        length; or the BDaily filters produce mismatched lengths.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> x = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
    >>> y = tse.TSeries(tse.qq(2020, 1), np.array([2.0, 4.0, 6.0]))
    >>> float(tse.cov(x, y))
    2.0
    """
    if y is None:
        if isinstance(x, MVTSeries):
            values = _resolve_values(
                x,
                skip_all_nans=skip_all_nans,
                skip_holidays=skip_holidays,
                holidays_map=holidays_map,
            )
            return np.cov(values, rowvar=False, ddof=1)
        # cov(t) → var(t) (scalar)
        return var(
            x,
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if not isinstance(x, TSeries) or not isinstance(y, TSeries):
        msg = (
            "cov(x, y) requires two TSeries arguments; got "
            f"{type(x).__name__!r} and {type(y).__name__!r}."
        )
        raise TypeError(msg)
    if x.frequency != y.frequency or x.firstdate != y.firstdate or len(x) != len(y):
        msg = (
            "cov(x, y) requires same-frequency same-firstdate same-length TSeries. "
            "Call cov(cleanedvalues(x), cleanedvalues(y)) directly for misaligned data."
        )
        raise ValueError(msg)
    xv = _resolve_values(
        x, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
    )
    yv = _resolve_values(
        y, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
    )
    if xv.shape[0] != yv.shape[0]:
        msg = (
            "cov(x, y): filtered arrays have unequal lengths "
            f"({xv.shape[0]} vs {yv.shape[0]}); supply matched holidays / NaN filters."
        )
        raise ValueError(msg)
    if xv.shape[0] < 2:
        return float("nan")
    return float(np.cov(xv, yv, ddof=1)[0, 1])

mean

mean(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Arithmetic mean.

For a :class:~tsecon.tseries.TSeries returns a scalar; for an :class:~tsecon.mvtseries.MVTSeries the default (axis=None) returns the overall mean (matching Julia's mean(::MVTSeries) which iterates the matrix flat). axis=0 reduces along rows to a single-row MVTSeries (per-column means); axis=1 reduces along columns to a 1-D TSeries (per-row means).

Parameters:

Name Type Description Default
t TSeries or MVTSeries

Input series. Any non-Unit frequency.

required
axis int or None

Reduction axis (NumPy convention).

  • None: reduce flat (scalar return; existing behaviour).
  • 0: per-column → single-row MVTSeries anchored at t.firstdate with the input column names. For a 1-D TSeries, equivalent to axis=None (scalar).
  • 1: per-row → 1-D TSeries indexed by the input range. ValueError for TSeries input (no axis 1).
None
skip_all_nans bool

BDaily only. If True, drop entries that are NaN in t.values before reducing. TypeError on non-BDaily.

False
skip_holidays bool

BDaily only. If True, drop entries on the dates marked by the holiday map (the explicit holidays_map if given, otherwise getoption('bdaily_holidays_map')).

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging the business days to include (True) / drop (False); AND-ed with the NaN mask when skip_all_nans=True. With axis=1 the filter masks output positions to NaN (preserving the contiguous range) rather than dropping rows.

None

Returns:

Type Description
float, MVTSeries, or TSeries

Scalar for axis=None; single-row MVTSeries for axis=0 on an MVTSeries; 1-D TSeries for axis=1 on an MVTSeries.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
>>> tse.mean(t)
2.5

Per-column reduction on an MVTSeries:

>>> m = tse.MVTSeries(tse.qq(2020, 1), ("a", "b"),
...                   np.array([[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]]))
>>> tse.mean(m, axis=0)["a"][tse.qq(2020, 1)]
2.0

Per-row reduction on an MVTSeries:

>>> tse.mean(m, axis=1)[tse.qq(2020, 1)]
5.5
Source code in src/tsecon/_stats.py
def mean(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Arithmetic mean.

    For a :class:`~tsecon.tseries.TSeries` returns a scalar; for an
    :class:`~tsecon.mvtseries.MVTSeries` the default (``axis=None``) returns
    the overall mean (matching Julia's ``mean(::MVTSeries)`` which iterates
    the matrix flat). ``axis=0`` reduces along rows to a single-row
    MVTSeries (per-column means); ``axis=1`` reduces along columns to a 1-D
    TSeries (per-row means).

    Parameters
    ----------
    t : TSeries or MVTSeries
        Input series. Any non-Unit frequency.
    axis : int or None, default None
        Reduction axis (NumPy convention).

        * ``None``: reduce flat (scalar return; existing behaviour).
        * ``0``: per-column → single-row MVTSeries anchored at
          ``t.firstdate`` with the input column names. For a 1-D TSeries,
          equivalent to ``axis=None`` (scalar).
        * ``1``: per-row → 1-D TSeries indexed by the input range.
          ``ValueError`` for TSeries input (no axis 1).
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop entries that are NaN in
        ``t.values`` before reducing. ``TypeError`` on non-BDaily.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries on the dates marked
        by the holiday map (the explicit ``holidays_map`` if given,
        otherwise ``getoption('bdaily_holidays_map')``).
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging the business days to
        include (``True``) / drop (``False``); ``AND``-ed with the
        NaN mask when ``skip_all_nans=True``. With ``axis=1`` the
        filter masks output positions to NaN (preserving the
        contiguous range) rather than dropping rows.

    Returns
    -------
    float, MVTSeries, or TSeries
        Scalar for ``axis=None``; single-row MVTSeries for ``axis=0`` on
        an MVTSeries; 1-D TSeries for ``axis=1`` on an MVTSeries.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
    >>> tse.mean(t)
    2.5

    Per-column reduction on an MVTSeries:

    >>> m = tse.MVTSeries(tse.qq(2020, 1), ("a", "b"),
    ...                   np.array([[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]]))
    >>> tse.mean(m, axis=0)["a"][tse.qq(2020, 1)]
    2.0

    Per-row reduction on an MVTSeries:

    >>> tse.mean(m, axis=1)[tse.qq(2020, 1)]
    5.5
    """
    if axis is None:
        values = _resolve_values(
            t, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
        )
        flat = _ravel_for_kernel(values)
        if flat is not None and flat.shape[0] > 0:
            return _dispatch_kernel(_CYTHON_AVAILABLE, mean_cython, mean_numpy, flat)
        return np.mean(values)
    if not isinstance(t, MVTSeries):
        _axis_for_tseries(axis)
        # axis=0 on TSeries → scalar path
        return mean(
            t,
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 0:
        return _per_column_reduce(
            t,
            lambda m: np.mean(m, axis=0),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 1:
        return _per_row_reduce(
            t,
            lambda m: np.mean(m, axis=1),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    raise _axis_value_error(axis)

median

median(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Median value.

Mirrors Julia's Statistics.median. For an even-length input the midpoint of the two middle values is returned.

Parameters:

Name Type Description Default
t TSeries or MVTSeries

Input series. Any non-Unit frequency.

required
axis int or None

Reduction axis (NumPy convention; see :func:mean for the return- shape contract). None → scalar. 0 → per-column → single-row MVTSeries. 1 → per-row → 1-D TSeries (MVTSeries only).

None
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float, MVTSeries, or TSeries

The median of the resolved values. Scalar for axis=None; single-row MVTSeries for axis=0; 1-D TSeries for axis=1.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
>>> float(tse.median(t))
2.5
Source code in src/tsecon/_stats.py
def median(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Median value.

    Mirrors Julia's ``Statistics.median``. For an even-length input the
    midpoint of the two middle values is returned.

    Parameters
    ----------
    t : TSeries or MVTSeries
        Input series. Any non-Unit frequency.
    axis : int or None, default None
        Reduction axis (NumPy convention; see :func:`mean` for the return-
        shape contract). ``None`` → scalar. ``0`` → per-column → single-row
        MVTSeries. ``1`` → per-row → 1-D TSeries (MVTSeries only).
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float, MVTSeries, or TSeries
        The median of the resolved values. Scalar for ``axis=None``;
        single-row MVTSeries for ``axis=0``; 1-D TSeries for ``axis=1``.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
    >>> float(tse.median(t))
    2.5
    """
    if axis is None:
        values = _resolve_values(
            t, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
        )
        return np.median(values)
    if not isinstance(t, MVTSeries):
        _axis_for_tseries(axis)
        return median(
            t,
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 0:
        return _per_column_reduce(
            t,
            lambda m: np.median(m, axis=0),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 1:
        return _per_row_reduce(
            t,
            lambda m: np.median(m, axis=1),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    raise _axis_value_error(axis)

quantile

quantile(
    t: TSeries | MVTSeries,
    p: float | ndarray,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

p-th quantile of the series' values.

Mirrors Julia's Statistics.quantile.

Parameters:

Name Type Description Default
t TSeries or MVTSeries

Input series. Any non-Unit frequency.

required
p float or array-like of float

Probability (or probabilities) in [0, 1]. Must be scalar when axis is not None — array-p × axis-reduction produces a 2-D return shape that does not fit the single-row-MVTSeries / 1-D-TSeries contract; raises TypeError in that combination.

required
axis int or None

Reduction axis (NumPy convention; see :func:mean for the return- shape contract). None → scalar (or ndarray when p is array-like). 0 → per-column → single-row MVTSeries. 1 → per-row → 1-D TSeries (MVTSeries only).

None
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float, ndarray, MVTSeries, or TSeries

Scalar when p is scalar and axis=None; ndarray (same shape as p) when axis=None and p is array-like; single-row MVTSeries for axis=0; 1-D TSeries for axis=1.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
>>> float(tse.quantile(t, 0.5))
2.5
Source code in src/tsecon/_stats.py
def quantile(
    t: TSeries | MVTSeries,
    p: float | np.ndarray,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """``p``-th quantile of the series' values.

    Mirrors Julia's ``Statistics.quantile``.

    Parameters
    ----------
    t : TSeries or MVTSeries
        Input series. Any non-Unit frequency.
    p : float or array-like of float
        Probability (or probabilities) in ``[0, 1]``. Must be scalar when
        ``axis`` is not ``None`` — array-``p`` × axis-reduction produces a
        2-D return shape that does not fit the single-row-MVTSeries /
        1-D-TSeries contract; raises ``TypeError`` in that combination.
    axis : int or None, default None
        Reduction axis (NumPy convention; see :func:`mean` for the return-
        shape contract). ``None`` → scalar (or ndarray when ``p`` is
        array-like). ``0`` → per-column → single-row MVTSeries. ``1`` →
        per-row → 1-D TSeries (MVTSeries only).
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float, ndarray, MVTSeries, or TSeries
        Scalar when ``p`` is scalar and ``axis=None``; ndarray (same shape
        as ``p``) when ``axis=None`` and ``p`` is array-like; single-row
        MVTSeries for ``axis=0``; 1-D TSeries for ``axis=1``.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0, 4.0]))
    >>> float(tse.quantile(t, 0.5))
    2.5
    """
    if axis is None:
        values = _resolve_values(
            t, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
        )
        return np.quantile(values, p)
    if not np.isscalar(p):
        msg = (
            "quantile(t, p, axis=...): p must be a scalar probability when axis is "
            f"not None; got p of type {type(p).__name__}. Call quantile separately "
            "per probability, or use axis=None to get the array-shaped return."
        )
        raise TypeError(msg)
    # Narrow p to a Python float for the per-axis paths — np.isscalar
    # admits both Python scalars and numpy generic scalars, but mypy's
    # function signature only narrows when the binding is float-typed.
    p_scalar: float = float(p)  # type: ignore[arg-type]
    if not isinstance(t, MVTSeries):
        _axis_for_tseries(axis)
        return quantile(
            t,
            p_scalar,
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 0:
        return _per_column_reduce(
            t,
            lambda m: np.quantile(m, p_scalar, axis=0),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 1:
        return _per_row_reduce(
            t,
            lambda m: np.quantile(m, p_scalar, axis=1),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    raise _axis_value_error(axis)

stats_is_cython

stats_is_cython() -> bool

Return True iff the Cython-compiled stats kernels were importable.

Useful for tests, benchmarks, and diagnostic prints — the public :func:mean / :func:var / :func:std / :func:cor are implementation-agnostic. When this returns False the same calls go through the pure-NumPy kernels in _stats_kernels.py; behaviour is identical, only speed differs.

Source code in src/tsecon/_stats.py
def stats_is_cython() -> bool:
    """Return True iff the Cython-compiled stats kernels were importable.

    Useful for tests, benchmarks, and diagnostic prints — the public
    :func:`mean` / :func:`var` / :func:`std` / :func:`cor` are
    implementation-agnostic. When this returns ``False`` the same calls
    go through the pure-NumPy kernels in ``_stats_kernels.py``;
    behaviour is identical, only speed differs.
    """
    return _CYTHON_AVAILABLE

std

std(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Sample standard deviation.

Uses ddof=1 to match Julia's Statistics.std (corrected=true by default). Pass np.std(t.values, ddof=0) directly if you need the population deviation.

Parameters:

Name Type Description Default
t TSeries or MVTSeries

Input series. Any non-Unit frequency.

required
axis int or None

Reduction axis (NumPy convention; see :func:mean for the return- shape contract). None → scalar. 0 → per-column → single-row MVTSeries. 1 → per-row → 1-D TSeries (MVTSeries only).

None
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float, MVTSeries, or TSeries

Sample standard deviation with ddof=1. Scalar for axis=None. Single-row MVTSeries for axis=0 on an MVTSeries. 1-D TSeries for axis=1 on an MVTSeries. Returns nan for an empty or single-element input.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
>>> tse.std(t)
1.0
Source code in src/tsecon/_stats.py
def std(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Sample standard deviation.

    Uses ``ddof=1`` to match Julia's ``Statistics.std`` (``corrected=true``
    by default). Pass ``np.std(t.values, ddof=0)`` directly if you need the
    population deviation.

    Parameters
    ----------
    t : TSeries or MVTSeries
        Input series. Any non-Unit frequency.
    axis : int or None, default None
        Reduction axis (NumPy convention; see :func:`mean` for the return-
        shape contract). ``None`` → scalar. ``0`` → per-column → single-row
        MVTSeries. ``1`` → per-row → 1-D TSeries (MVTSeries only).
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float, MVTSeries, or TSeries
        Sample standard deviation with ``ddof=1``. Scalar for ``axis=None``.
        Single-row MVTSeries for ``axis=0`` on an MVTSeries. 1-D TSeries
        for ``axis=1`` on an MVTSeries. Returns ``nan`` for an empty or
        single-element input.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
    >>> tse.std(t)
    1.0
    """
    if axis is None:
        values = _resolve_values(
            t, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
        )
        flat = _ravel_for_kernel(values)
        if flat is not None and flat.shape[0] > 1:
            return _dispatch_kernel(_CYTHON_AVAILABLE, std_cython, std_numpy, flat, 1)
        return np.std(values, ddof=1)
    if not isinstance(t, MVTSeries):
        _axis_for_tseries(axis)
        return std(
            t,
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 0:
        return _per_column_reduce(
            t,
            lambda m: np.std(m, axis=0, ddof=1),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 1:
        return _per_row_reduce(
            t,
            lambda m: np.std(m, axis=1, ddof=1),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    raise _axis_value_error(axis)

stdm

stdm(
    t: TSeries,
    m: float,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Sample standard deviation with the mean m supplied externally.

Mirrors Julia's Statistics.stdm. Use this when you already have the mean (perhaps a known population mean, or one shared across several reductions) and want to avoid a redundant pass over t.

Parameters:

Name Type Description Default
t TSeries

Input series. Any non-Unit frequency.

required
m float

The mean to centre around. Not validated against mean(t).

required
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float

sqrt(sum((t - m)**2) / (n - 1)). Returns nan when n <= 1.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
>>> tse.stdm(t, 2.0)
1.0
Source code in src/tsecon/_stats.py
def stdm(
    t: TSeries,
    m: float,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Sample standard deviation with the mean ``m`` supplied externally.

    Mirrors Julia's ``Statistics.stdm``. Use this when you already have
    the mean (perhaps a known population mean, or one shared across
    several reductions) and want to avoid a redundant pass over ``t``.

    Parameters
    ----------
    t : TSeries
        Input series. Any non-Unit frequency.
    m : float
        The mean to centre around. Not validated against ``mean(t)``.
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float
        ``sqrt(sum((t - m)**2) / (n - 1))``. Returns ``nan`` when
        ``n <= 1``.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
    >>> tse.stdm(t, 2.0)
    1.0
    """
    values = _resolve_values(
        t, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
    )
    diffs = np.asarray(values, dtype=float) - float(m)
    n = diffs.shape[0]
    if n <= 1:
        return float("nan")
    return float(np.sqrt(np.sum(diffs * diffs) / (n - 1)))

var

var(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Sample variance (ddof=1 to match Julia's Statistics.var).

Parameters:

Name Type Description Default
t TSeries or MVTSeries

Input series. Any non-Unit frequency.

required
axis int or None

Reduction axis (NumPy convention; see :func:mean for the return- shape contract). None → scalar. 0 → per-column → single-row MVTSeries. 1 → per-row → 1-D TSeries (MVTSeries only).

None
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float, MVTSeries, or TSeries

Sample variance with ddof=1. Scalar for axis=None; single-row MVTSeries for axis=0; 1-D TSeries for axis=1. Returns nan for an empty or single-element input.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
>>> tse.var(t)
1.0
Source code in src/tsecon/_stats.py
def var(
    t: TSeries | MVTSeries,
    *,
    axis: int | None = None,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Sample variance (``ddof=1`` to match Julia's ``Statistics.var``).

    Parameters
    ----------
    t : TSeries or MVTSeries
        Input series. Any non-Unit frequency.
    axis : int or None, default None
        Reduction axis (NumPy convention; see :func:`mean` for the return-
        shape contract). ``None`` → scalar. ``0`` → per-column → single-row
        MVTSeries. ``1`` → per-row → 1-D TSeries (MVTSeries only).
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float, MVTSeries, or TSeries
        Sample variance with ``ddof=1``. Scalar for ``axis=None``;
        single-row MVTSeries for ``axis=0``; 1-D TSeries for ``axis=1``.
        Returns ``nan`` for an empty or single-element input.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
    >>> tse.var(t)
    1.0
    """
    if axis is None:
        values = _resolve_values(
            t, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
        )
        flat = _ravel_for_kernel(values)
        if flat is not None and flat.shape[0] > 1:
            return _dispatch_kernel(_CYTHON_AVAILABLE, var_cython, var_numpy, flat, 1)
        return np.var(values, ddof=1)
    if not isinstance(t, MVTSeries):
        _axis_for_tseries(axis)
        return var(
            t,
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 0:
        return _per_column_reduce(
            t,
            lambda m: np.var(m, axis=0, ddof=1),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    if axis == 1:
        return _per_row_reduce(
            t,
            lambda m: np.var(m, axis=1, ddof=1),
            skip_all_nans=skip_all_nans,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
    raise _axis_value_error(axis)

varm

varm(
    t: TSeries,
    m: float,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any

Sample variance with the mean m supplied externally.

Mirrors Julia's Statistics.varm. The variance counterpart of :func:stdm.

Parameters:

Name Type Description Default
t TSeries

Input series. Any non-Unit frequency.

required
m float

The mean to centre around. Not validated against mean(t).

required
skip_all_nans bool

BDaily only. If True, drop NaN entries before reducing.

False
skip_holidays bool

BDaily only. If True, drop entries marked by the holiday map.

False
holidays_map TSeries

BDaily only. Boolean TSeries flagging which business days to keep.

None

Returns:

Type Description
float

sum((t - m)**2) / (n - 1). Returns nan when n <= 1.

Examples:

>>> import numpy as np
>>> import tsecon as tse
>>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
>>> tse.varm(t, 2.0)
1.0
Source code in src/tsecon/_stats.py
def varm(
    t: TSeries,
    m: float,
    *,
    skip_all_nans: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> Any:
    """Sample variance with the mean ``m`` supplied externally.

    Mirrors Julia's ``Statistics.varm``. The variance counterpart of
    :func:`stdm`.

    Parameters
    ----------
    t : TSeries
        Input series. Any non-Unit frequency.
    m : float
        The mean to centre around. Not validated against ``mean(t)``.
    skip_all_nans : bool, default False
        BDaily only. If ``True``, drop NaN entries before reducing.
    skip_holidays : bool, default False
        BDaily only. If ``True``, drop entries marked by the holiday map.
    holidays_map : TSeries, optional
        BDaily only. Boolean TSeries flagging which business days to keep.

    Returns
    -------
    float
        ``sum((t - m)**2) / (n - 1)``. Returns ``nan`` when ``n <= 1``.

    Examples
    --------
    >>> import numpy as np
    >>> import tsecon as tse
    >>> t = tse.TSeries(tse.qq(2020, 1), np.array([1.0, 2.0, 3.0]))
    >>> tse.varm(t, 2.0)
    1.0
    """
    values = _resolve_values(
        t, skip_all_nans=skip_all_nans, skip_holidays=skip_holidays, holidays_map=holidays_map
    )
    diffs = np.asarray(values, dtype=float) - float(m)
    n = diffs.shape[0]
    if n <= 1:
        return float("nan")
    return float(np.sum(diffs * diffs) / (n - 1))

compare

compare(
    x: Any,
    y: Any,
    *,
    name: str = "_",
    showequal: bool = False,
    ignoremissing: bool = False,
    quiet: bool = False,
    left: str = "left",
    right: str = "right",
    atol: float = 0.0,
    rtol: float | None = None,
    nans: bool = False,
    trange: MITRange | None = None,
) -> CompareResult

Recursively compare x and y, returning a :class:CompareResult.

The walk dispatches over:

  • :class:~tsecon.workspace.Workspace, :class:~tsecon.mvtseries.MVTSeries, and :class:collections.abc.Mapping — treated as name-keyed containers and recursed key-by-key. Missing keys produce "missing in <left|right>" lines (unless ignoremissing=True).
  • :class:~tsecon.tseries.TSeries — compared range-by-range using :func:numpy.allclose with the given atol / rtol / nans. Mismatched ranges count as different (unless ignoremissing=True, in which case the intersection is compared).
  • NumPy arrays of numbers — compared by shape + element-wise :func:numpy.allclose.
  • NumPy arrays of other dtypes — walked element-by-element.
  • Scalars — compared via :func:numpy.isclose.
  • Everything else — compared with ==.

Parameters:

Name Type Description Default
x Any

The two values to compare.

required
y Any

The two values to compare.

required
name str

Top-level name in the printed/structured diff ("_" by default — matches Julia's Symbol("_")).

'_'
showequal bool

When True, also emit "name: same" lines for matching leaves. Default False (only differences are reported, plus a final top-level summary line).

False
ignoremissing bool

When True, ignore keys present in one side but not the other (they don't print and don't affect equal). Default False.

False
quiet bool

When True, suppress printing to stdout. The :class:CompareResult is still populated either way.

False
left str

Names used in "missing in <name>" messages. Default "left" / "right".

'left'
right str

Names used in "missing in <name>" messages. Default "left" / "right".

'left'
atol float

Tolerances forwarded to :func:numpy.isclose / :func:numpy.allclose. rtol=None (the default) resolves to :math:\\sqrt{\\varepsilon} when atol == 0 and to 0 when atol > 0 (matching Julia's isapprox precedent).

0.0
rtol float

Tolerances forwarded to :func:numpy.isclose / :func:numpy.allclose. rtol=None (the default) resolves to :math:\\sqrt{\\varepsilon} when atol == 0 and to 0 when atol > 0 (matching Julia's isapprox precedent).

0.0
nans float

Tolerances forwarded to :func:numpy.isclose / :func:numpy.allclose. rtol=None (the default) resolves to :math:\\sqrt{\\varepsilon} when atol == 0 and to 0 when atol > 0 (matching Julia's isapprox precedent).

0.0
trange MITRange | None

Optional :class:~tsecon.mitrange.MITRange restricting the compared window for TSeries-vs-TSeries leaves only.

None

Returns:

Type Description
CompareResult

Truthy iff x and y compared as equal; .differences carries the recursive diff regardless of quiet.

Notes

Julia's accompanying @compare macro folds into this same function. The macro existed only to capture the input variable names for printing; Python callers can pass left= and right= if they want the same labelling.

Source code in src/tsecon/_various.py
def compare(
    x: Any,
    y: Any,
    *,
    name: str = "_",
    showequal: bool = False,
    ignoremissing: bool = False,
    quiet: bool = False,
    left: str = "left",
    right: str = "right",
    atol: float = 0.0,
    rtol: float | None = None,
    nans: bool = False,
    trange: MITRange | None = None,
) -> CompareResult:
    r"""Recursively compare ``x`` and ``y``, returning a :class:`CompareResult`.

    The walk dispatches over:

    * :class:`~tsecon.workspace.Workspace`, :class:`~tsecon.mvtseries.MVTSeries`,
      and :class:`collections.abc.Mapping` — treated as name-keyed
      containers and recursed key-by-key. Missing keys produce
      ``"missing in <left|right>"`` lines (unless ``ignoremissing=True``).
    * :class:`~tsecon.tseries.TSeries` — compared range-by-range using
      :func:`numpy.allclose` with the given ``atol`` / ``rtol`` / ``nans``.
      Mismatched ranges count as different (unless ``ignoremissing=True``,
      in which case the intersection is compared).
    * NumPy arrays of numbers — compared by shape + element-wise
      :func:`numpy.allclose`.
    * NumPy arrays of other dtypes — walked element-by-element.
    * Scalars — compared via :func:`numpy.isclose`.
    * Everything else — compared with ``==``.

    Parameters
    ----------
    x, y
        The two values to compare.
    name
        Top-level name in the printed/structured diff (``"_"`` by default —
        matches Julia's ``Symbol("_")``).
    showequal
        When True, also emit ``"name: same"`` lines for matching leaves.
        Default False (only differences are reported, plus a final
        top-level summary line).
    ignoremissing
        When True, ignore keys present in one side but not the other
        (they don't print and don't affect ``equal``). Default False.
    quiet
        When True, suppress printing to stdout. The :class:`CompareResult`
        is still populated either way.
    left, right
        Names used in ``"missing in <name>"`` messages. Default
        ``"left"`` / ``"right"``.
    atol, rtol, nans
        Tolerances forwarded to :func:`numpy.isclose` /
        :func:`numpy.allclose`. ``rtol=None`` (the default) resolves to
        :math:`\\sqrt{\\varepsilon}` when ``atol == 0`` and to ``0`` when
        ``atol > 0`` (matching Julia's ``isapprox`` precedent).
    trange
        Optional :class:`~tsecon.mitrange.MITRange` restricting the
        compared window for TSeries-vs-TSeries leaves only.

    Returns
    -------
    CompareResult
        Truthy iff ``x`` and ``y`` compared as equal; ``.differences``
        carries the recursive diff regardless of ``quiet``.

    Notes
    -----
    Julia's accompanying ``@compare`` macro folds into this same
    function. The macro existed only to capture the input variable names
    for printing; Python callers can pass ``left=`` and ``right=`` if
    they want the same labelling.
    """
    if rtol is None:
        rtol = _DEFAULT_RTOL if atol == 0.0 else 0.0

    result = CompareResult(equal=True)
    kw: dict[str, Any] = {
        "showequal": showequal,
        "ignoremissing": ignoremissing,
        "quiet": quiet,
        "left": left,
        "right": right,
        "atol": atol,
        "rtol": rtol,
        "nans": nans,
        "trange": trange,
    }
    _compare_recurse(x, y, [name], result, kw)
    return result

overlay

overlay(*args: Any, rng: MITRange | None = None) -> Any

Return the first non-missing value, position-by-position, from left to right.

"Missing" is defined by the dtype-appropriate typenan (NaN for floats, iinfo(dtype).max for integers, False for booleans). The dispatch is on the argument types — all arguments must agree on a container family for the recursive form to apply:

  • All :class:~tsecon.tseries.TSeries — build a new TSeries over either rng= (when given) or the union of all input ranges (rangeof_span(*args)). For each position, the leftmost input that has a non-typenan value at that position wins. The output dtype is the NumPy promotion of all input dtypes.
  • All :class:~tsecon.mvtseries.MVTSeries — return an MVTSeries over the union range and the ordered union of column names; each column is the overlay of the corresponding TSeries columns.
  • All Workspace / MVTSeries / Mapping — return a :class:~tsecon.workspace.Workspace. For each key present in any input, collect the values across inputs that have it and recurse; the recursive call may dispatch back into the TSeries / Workspace / MVTSeries / scalar paths.
  • Mixed types (e.g. a TSeries and a scalar) — fall through to the scalar walk: return the leftmost non-typenan value.

Parameters:

Name Type Description Default
*args Any

One or more values to overlay. At least one is required.

()
rng MITRange | None

Optional :class:~tsecon.mitrange.MITRange to force the output range. Only valid when all arguments are TSeries; raises :class:TypeError otherwise.

None

Returns:

Type Description
Any

A TSeries / MVTSeries / Workspace / scalar matching the input family.

Raises:

Type Description
TypeError

On empty argument list, on mixed-frequency inputs, or on rng= passed with non-TSeries arguments.

Source code in src/tsecon/_various.py
def overlay(*args: Any, rng: MITRange | None = None) -> Any:
    """Return the first non-missing value, position-by-position, from left to right.

    "Missing" is defined by the dtype-appropriate ``typenan`` (NaN for
    floats, ``iinfo(dtype).max`` for integers, ``False`` for booleans).
    The dispatch is on the argument types — all arguments must agree on a
    container family for the recursive form to apply:

    * **All :class:`~tsecon.tseries.TSeries`** — build a new TSeries over
      either ``rng=`` (when given) or the union of all input ranges
      (``rangeof_span(*args)``). For each position, the leftmost input
      that has a non-typenan value at that position wins. The output
      dtype is the NumPy promotion of all input dtypes.
    * **All :class:`~tsecon.mvtseries.MVTSeries`** — return an MVTSeries
      over the union range and the ordered union of column names; each
      column is the overlay of the corresponding TSeries columns.
    * **All Workspace / MVTSeries / Mapping** — return a
      :class:`~tsecon.workspace.Workspace`. For each key present in any
      input, collect the values across inputs that have it and recurse;
      the recursive call may dispatch back into the TSeries / Workspace
      / MVTSeries / scalar paths.
    * **Mixed types** (e.g. a TSeries and a scalar) — fall through to
      the scalar walk: return the leftmost non-typenan value.

    Parameters
    ----------
    *args
        One or more values to overlay. At least one is required.
    rng
        Optional :class:`~tsecon.mitrange.MITRange` to force the output
        range. Only valid when all arguments are TSeries; raises
        :class:`TypeError` otherwise.

    Returns
    -------
    Any
        A TSeries / MVTSeries / Workspace / scalar matching the input
        family.

    Raises
    ------
    TypeError
        On empty argument list, on mixed-frequency inputs, or on
        ``rng=`` passed with non-TSeries arguments.
    """
    if not args:
        msg = "overlay() requires at least one argument."
        raise TypeError(msg)

    if all(isinstance(a, TSeries) for a in args):
        if rng is None:
            rng = rangeof_span(*(t.range for t in args))
        elif not isinstance(rng, MITRange):
            # Defensive runtime check: the signature types ``rng`` as
            # ``MITRange | None`` so mypy considers this branch unreachable,
            # but callers may pass Any-typed values (e.g. from JSON
            # deserialization) and we'd rather raise at the call site than
            # produce a confusing AttributeError deep inside _overlay_tseries.
            msg = f"overlay rng= must be an MITRange; got {type(rng).__name__}."  # type: ignore[unreachable]
            raise TypeError(msg)
        return _overlay_tseries(rng, *args)

    if rng is not None:
        msg = "overlay(..., rng=) is only valid when all arguments are TSeries."
        raise TypeError(msg)

    if all(isinstance(a, MVTSeries) for a in args):
        return _overlay_mvtseries(*args)

    if all(_is_like_workspace(a) for a in args):
        return _overlay_workspaces(*args)

    # Scalar / mixed fallback: first non-typenan wins (Julia's
    # `overlay(head, tail...) = istypenan(head) ? overlay(tail...) : head`).
    return _overlay_scalar(args)

reindex

reindex(
    x: Any,
    old_to_new: tuple[MIT, MIT],
    *,
    copy: bool = False,
) -> Any

Shift every MIT-keyed position so that old maps to new.

old_to_new is a 2-tuple (old_mit, new_mit). In MIT-value space, every output position is the input position plus new_mit.value - old_mit.value, and the result carries new_mit.frequency as its frequency label.

Parameters:

Name Type Description Default
x Any

The value to reindex. Supported types:

  • :class:~tsecon.mit.MIT — single instant.
  • :class:~tsecon.mitrange.MITRange — both endpoints shifted; step is preserved.
  • :class:~tsecon.tseries.TSeries — anchor shifted; values unchanged.
  • :class:~tsecon.mvtseries.MVTSeries — same, on the matrix.
  • :class:~tsecon.workspace.Workspace — members whose frequency matches old_mit.frequency are reindexed (recursively for nested Workspaces); other members are carried through.
required
old_to_new tuple[MIT, MIT]

Pair (old_mit, new_mit): the input MIT (or one matching the input's frequency) to remap, and the target MIT (with the desired output frequency).

required
copy bool

When True, force an independent values buffer for the returned :class:~tsecon.tseries.TSeries / :class:~tsecon.mvtseries.MVTSeries and apply :func:copy.copy to non-reindexable Workspace members. Default False matches the wrap-by-default contract of :mod:tsecon constructors (see the project's design notes on constructor copy semantics).

False

Returns:

Type Description
The same kind of object as ``x``, with the MIT label shifted.

Raises:

Type Description
TypeError

On a malformed old_to_new argument, an unsupported x type, or a frequency mismatch between x and old_mit.

Examples:

Re-anchor a quarterly TSeries from a 2021Q1 origin to a Unit 1-based origin::

>>> from tsecon import TSeries, qq, period, Unit, reindex
>>> ts = TSeries(qq(2021, 1), [1.0, 2.0, 3.0])
>>> reindex(ts, (qq(2021, 1), period(Unit(), 1))).firstdate
1U
Source code in src/tsecon/_various.py
def reindex(
    x: Any,
    old_to_new: tuple[MIT, MIT],
    *,
    copy: bool = False,
) -> Any:
    """Shift every MIT-keyed position so that ``old`` maps to ``new``.

    ``old_to_new`` is a 2-tuple ``(old_mit, new_mit)``. In MIT-value
    space, every output position is the input position plus
    ``new_mit.value - old_mit.value``, and the result carries
    ``new_mit.frequency`` as its frequency label.

    Parameters
    ----------
    x
        The value to reindex. Supported types:

        * :class:`~tsecon.mit.MIT` — single instant.
        * :class:`~tsecon.mitrange.MITRange` — both endpoints shifted;
          ``step`` is preserved.
        * :class:`~tsecon.tseries.TSeries` — anchor shifted; values
          unchanged.
        * :class:`~tsecon.mvtseries.MVTSeries` — same, on the matrix.
        * :class:`~tsecon.workspace.Workspace` — members whose
          frequency matches ``old_mit.frequency`` are reindexed
          (recursively for nested Workspaces); other members are
          carried through.

    old_to_new
        Pair ``(old_mit, new_mit)``: the input MIT (or one matching
        the input's frequency) to remap, and the target MIT (with
        the desired output frequency).
    copy
        When True, force an independent values buffer for the returned
        :class:`~tsecon.tseries.TSeries` / :class:`~tsecon.mvtseries.MVTSeries`
        and apply :func:`copy.copy` to non-reindexable Workspace members.
        Default False matches the wrap-by-default contract of
        :mod:`tsecon` constructors (see the project's design notes on
        constructor copy semantics).

    Returns
    -------
    The same kind of object as ``x``, with the MIT label shifted.

    Raises
    ------
    TypeError
        On a malformed ``old_to_new`` argument, an unsupported ``x``
        type, or a frequency mismatch between ``x`` and ``old_mit``.

    Examples
    --------
    Re-anchor a quarterly TSeries from a 2021Q1 origin to a Unit
    1-based origin::

        >>> from tsecon import TSeries, qq, period, Unit, reindex
        >>> ts = TSeries(qq(2021, 1), [1.0, 2.0, 3.0])
        >>> reindex(ts, (qq(2021, 1), period(Unit(), 1))).firstdate
        1U
    """
    if not isinstance(old_to_new, tuple) or len(old_to_new) != 2:
        # Defensive: signature types `old_to_new` as `tuple[MIT, MIT]` so
        # mypy considers this branch unreachable, but Any-typed callers
        # (e.g. JSON deserialization) need the friendly error.
        msg = (  # type: ignore[unreachable]
            f"reindex pair must be a 2-tuple (old_mit, new_mit); got {type(old_to_new).__name__}."
        )
        raise TypeError(msg)
    old_mit, new_mit = old_to_new
    if not isinstance(old_mit, MIT) or not isinstance(new_mit, MIT):
        msg = (  # type: ignore[unreachable]
            "reindex pair must contain two MIT instances; got "
            f"({type(old_mit).__name__}, {type(new_mit).__name__})."
        )
        raise TypeError(msg)
    return _reindex_dispatch(x, old_mit, new_mit, copy=copy)

extend_series

extend_series(
    target: FrequencyLike,
    t: TSeries,
    *,
    direction: ExtendDirection = "both",
    method: ExtendMethod = "mean",
) -> TSeries

Pad a TSeries to align with target-frequency period boundaries.

Returns a new TSeries (the input is not mutated). method="mean" fills each padded section with the mean of the existing values that fall within the spanned target period; method="end" repeats the nearest in-range value (last value for the trailing pad, first in-range value for the leading pad — matching Julia's behaviour when there is exactly one value in the spanning period).

Source / target may be any non-Unit frequency. For a BDaily source the in-range first/last value used by method="end", and the per-period mean used by method="mean", are computed via cleanedvalues(...; skip_all_nans=True) so leading / trailing NaNs do not poison the fill (matches Julia's BDaily branch at fconvert_helpers.jl lines 309-326).

Source code in src/tsecon/fconvert/_tseries.py
def extend_series(
    target: FrequencyLike,
    t: TSeries,
    *,
    direction: ExtendDirection = "both",
    method: ExtendMethod = "mean",
) -> TSeries:
    """Pad a TSeries to align with target-frequency period boundaries.

    Returns a new TSeries (the input is not mutated). ``method="mean"`` fills
    each padded section with the mean of the existing values that fall within
    the spanned target period; ``method="end"`` repeats the nearest in-range
    value (last value for the trailing pad, first in-range value for the
    leading pad — matching Julia's behaviour when there is exactly one value
    in the spanning period).

    Source / target may be any non-Unit frequency. For a BDaily source the
    in-range first/last value used by ``method="end"``, and the per-period
    mean used by ``method="mean"``, are computed via
    ``cleanedvalues(...; skip_all_nans=True)`` so leading / trailing NaNs do
    not poison the fill (matches Julia's BDaily branch at
    ``fconvert_helpers.jl`` lines 309-326).
    """
    if direction not in ("both", "begin", "end"):
        msg = f"direction must be 'both', 'begin', or 'end'. Received: {direction!r}."
        raise ValueError(msg)
    if method not in ("mean", "end"):
        msg = f"method must be 'mean' or 'end'. Received: {method!r}."
        raise ValueError(msg)
    f_to = sanitize_frequency(target)
    f_from = t.frequency
    _reject_unit(f_to, what="target")
    _reject_unit(f_from, what="source")
    out = t.copy()
    if direction in ("begin", "both"):
        _extend_begin(f_to, out, method=method)
    if direction in ("end", "both"):
        _extend_end(f_to, out, method=method)
    return out

fconvert_is_cython

fconvert_is_cython() -> bool

Return True iff the Cython-compiled fconvert kernel was importable.

Useful for tests, benchmarks, and diagnostic prints — the public :func:fconvert_tseries is implementation-agnostic. When this returns False the same calls go through the pure-NumPy kernel in _fconvert_kernels.py; behaviour is identical, only speed differs.

Source code in src/tsecon/fconvert/_tseries.py
def fconvert_is_cython() -> bool:
    """Return True iff the Cython-compiled fconvert kernel was importable.

    Useful for tests, benchmarks, and diagnostic prints — the public
    :func:`fconvert_tseries` is implementation-agnostic. When this
    returns ``False`` the same calls go through the pure-NumPy kernel
    in ``_fconvert_kernels.py``; behaviour is identical, only speed
    differs.
    """
    return _CYTHON_AVAILABLE

fconvert_mit

fconvert_mit(
    target: FrequencyLike,
    mit_from: MIT,
    *,
    ref: Ref = "end",
    round_to: RoundTo = "current",
) -> MIT

Convert mit_from to target.

For YP→YP and any Calendar-involving conversion ref selects whether to align to the end of the source period ("end", the default) or the start ("begin"). Mirrors Julia's fconvert(F_to, MIT_from; ref=:end).

When the target is :class:BDaily, round_to controls how a date that lands on a weekend is resolved: "current" (default) raises on weekends, "previous" snaps to the preceding business day, "next" snaps forward.

Examples:

>>> from tsecon import yy, Quarterly
>>> fconvert_mit(Quarterly, yy(22), ref="end")
22Q4
>>> fconvert_mit(Quarterly, yy(22), ref="begin")
22Q1
Source code in src/tsecon/fconvert/_mit.py
def fconvert_mit(
    target: FrequencyLike,
    mit_from: MIT,
    *,
    ref: Ref = "end",
    round_to: RoundTo = "current",
) -> MIT:
    """Convert ``mit_from`` to ``target``.

    For YP→YP and any Calendar-involving conversion ``ref`` selects whether to
    align to the end of the source period (``"end"``, the default) or the start
    (``"begin"``). Mirrors Julia's ``fconvert(F_to, MIT_from; ref=:end)``.

    When the target is :class:`BDaily`, ``round_to`` controls how a date that
    lands on a weekend is resolved: ``"current"`` (default) raises on
    weekends, ``"previous"`` snaps to the preceding business day, ``"next"``
    snaps forward.

    Examples
    --------
    >>> from tsecon import yy, Quarterly
    >>> fconvert_mit(Quarterly, yy(22), ref="end")
    22Q4
    >>> fconvert_mit(Quarterly, yy(22), ref="begin")
    22Q1
    """
    f_to = sanitize_frequency(target)
    f_from = mit_from.frequency
    if ref not in ("begin", "end"):
        msg = f"ref argument must be 'begin' or 'end'. Received: {ref!r}."
        raise ValueError(msg)
    # Same-frequency identity (works for Unit too).
    if f_to == f_from:
        return mit_from
    _reject_unit(f_to, what="target")
    _reject_unit(f_from, what="source")
    # YP → YP — closed-form formula for performance.
    if isinstance(f_to, YPFrequency) and isinstance(f_from, YPFrequency):
        return _fconvert_mit_yp_to_yp(f_to, mit_from, ref=ref)
    # BDaily target — date-based with rounding policy.
    if isinstance(f_to, BDaily):
        return _fconvert_mit_to_bdaily(mit_from, ref=ref, round_to=round_to)
    # Daily target.
    if isinstance(f_to, Daily):
        return _fconvert_mit_to_daily(mit_from, ref=ref)
    # Any remaining target (YP from Calendar, Weekly from anywhere) — go via
    # ``_get_out_indices`` over the relevant boundary date.
    if isinstance(f_to, (YPFrequency, Weekly)):
        date_ref = mit_to_date(mit_from, ref=ref)
        return _get_out_indices(f_to, [date_ref])[0]
    msg = (
        f"fconvert: conversion from {type(f_from).__name__} "
        f"to {type(f_to).__name__} not implemented."
    )
    raise NotImplementedError(msg)

fconvert_parts

fconvert_parts(
    target: FrequencyLike,
    mit_from: MIT,
    *,
    ref: Ref = "end",
) -> tuple[int, int, int]

Return (to_period, from_month, to_month) for the YP→YP boundary.

With ref="end" the second / third elements are the end-month-of-year of the source / target periods; with ref="begin" they are the start months. Mirrors Julia's fconvert_parts. YP-only; raises for any Calendar source or target.

Source code in src/tsecon/fconvert/_mit.py
def fconvert_parts(
    target: FrequencyLike,
    mit_from: MIT,
    *,
    ref: Ref = "end",
) -> tuple[int, int, int]:
    """Return ``(to_period, from_month, to_month)`` for the YP→YP boundary.

    With ``ref="end"`` the second / third elements are the end-month-of-year
    of the source / target periods; with ``ref="begin"`` they are the start
    months. Mirrors Julia's ``fconvert_parts``. YP-only; raises for any
    Calendar source or target.
    """
    f_to = sanitize_frequency(target)
    f_from = mit_from.frequency
    if not isinstance(f_to, YPFrequency) or not isinstance(f_from, YPFrequency):
        msg = "fconvert_parts is YP-only; pass YP source and YP target."
        raise TypeError(msg)
    mpp_from = 12 // ppy(f_from)
    mpp_to = 12 // ppy(f_to)
    from_month_adjustment = endperiod(f_from) - mpp_from
    to_month_adjustment = endperiod(f_to) - mpp_to
    if ref == "begin":
        from_start_month = int(mit_from) * mpp_from + 1 + from_month_adjustment
        to_period = (from_start_month - to_month_adjustment - 1) // mpp_to
        to_start_month = to_period * mpp_to + 1 + to_month_adjustment
        return to_period, from_start_month, to_start_month
    # ref == "end" (the Literal narrowing rules out anything else)
    from_end_month = (int(mit_from) + 1) * mpp_from + from_month_adjustment
    to_period = (from_end_month - to_month_adjustment - 1) // mpp_to
    to_end_month = (to_period + 1) * mpp_to + to_month_adjustment
    return to_period, from_end_month, to_end_month

fconvert_range

fconvert_range(
    target: FrequencyLike,
    range_from: MITRange,
    *,
    trim: Trim = "both",
    parts: Literal[False] = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> MITRange
fconvert_range(
    target: FrequencyLike,
    range_from: MITRange,
    *,
    trim: Trim = "both",
    parts: Literal[True],
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> tuple[int, int, int, int, int, int]
fconvert_range(
    target: FrequencyLike,
    range_from: MITRange,
    *,
    trim: Trim = "both",
    parts: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> MITRange | tuple[int, int, int, int, int, int]

Convert range_from to target.

trim controls whether the start and / or end of the output range are truncated when the source range begins or ends partway through a target period. With parts=True (YP→YP only) returns the six-tuple (fi_to_period, fi_from_start_month, fi_to_start_month, li_to_period, li_from_end_month, li_to_end_month) used by the TSeries conversion path.

For BDaily-source lower-frequency conversion, skip_holidays / holidays_map shift the boundary predecessor / successor past consecutive holidays before applying the truncation check (mirrors fconvert_mit.jl lines 239-258). skip_holidays=True consults the global getoption('bdaily_holidays_map') map; holidays_map=t overrides it. The kwargs are only meaningful when range_from's frequency is BDaily and the conversion is to a lower frequency; elsewhere they're accepted as no-ops to keep the dispatcher uniform.

Examples:

>>> from tsecon import yy, mitrange, Quarterly
>>> fconvert_range(Quarterly, mitrange(yy(22), yy(24)))
22Q1:24Q4
Source code in src/tsecon/fconvert/_mit.py
def fconvert_range(
    target: FrequencyLike,
    range_from: MITRange,
    *,
    trim: Trim = "both",
    parts: bool = False,
    skip_holidays: bool = False,
    holidays_map: TSeries | None = None,
) -> MITRange | tuple[int, int, int, int, int, int]:
    """Convert ``range_from`` to ``target``.

    ``trim`` controls whether the start and / or end of the output range are
    truncated when the source range begins or ends partway through a target
    period. With ``parts=True`` (YP→YP only) returns the six-tuple
    ``(fi_to_period, fi_from_start_month, fi_to_start_month, li_to_period,
    li_from_end_month, li_to_end_month)`` used by the TSeries conversion path.

    For BDaily-source lower-frequency conversion, ``skip_holidays`` /
    ``holidays_map`` shift the boundary predecessor / successor past
    consecutive holidays before applying the truncation check (mirrors
    ``fconvert_mit.jl`` lines 239-258). ``skip_holidays=True`` consults the
    global ``getoption('bdaily_holidays_map')`` map; ``holidays_map=t``
    overrides it. The kwargs are only meaningful when ``range_from``'s
    frequency is BDaily *and* the conversion is to a lower frequency;
    elsewhere they're accepted as no-ops to keep the dispatcher uniform.

    Examples
    --------
    >>> from tsecon import yy, mitrange, Quarterly
    >>> fconvert_range(Quarterly, mitrange(yy(22), yy(24)))
    22Q1:24Q4
    """
    f_to = sanitize_frequency(target)
    f_from = range_from.frequency
    if trim not in ("both", "begin", "end"):
        msg = f"trim argument must be 'both', 'begin', or 'end'. Received: {trim!r}."
        raise ValueError(msg)
    # Same-frequency identity (mirrors the ``F_to == F_from`` Julia overload; works for Unit).
    if f_to == f_from:
        if parts:
            msg = "parts=True is only meaningful for cross-frequency conversions."
            raise ValueError(msg)
        return range_from
    _reject_unit(f_to, what="target")
    _reject_unit(f_from, what="source")
    # YP → YP: closed-form formula + `parts` introspection support.
    if isinstance(f_to, YPFrequency) and isinstance(f_from, YPFrequency):
        return _fconvert_range_yp_to_yp(f_to, range_from, trim=trim, parts=parts)
    if parts:
        msg = "parts=True is only supported for YP → YP conversions."
        raise ValueError(msg)
    # Daily target — date-based.
    if isinstance(f_to, Daily):
        return _fconvert_range_to_daily(range_from)
    # BDaily target — date-based.
    if isinstance(f_to, BDaily):
        return _fconvert_range_to_bdaily(range_from)
    # YP-or-Weekly target with anything else (Calendar source, or YP→Weekly):
    # use the date-based path.
    if isinstance(f_to, (YPFrequency, Weekly)) and isinstance(
        f_from, (Daily, BDaily, Weekly, YPFrequency)
    ):
        fi, li, ts, te = _fconvert_using_dates_parts(
            f_to,
            range_from,
            trim=trim,
            skip_holidays=skip_holidays,
            holidays_map=holidays_map,
        )
        # The truncation arithmetic differs slightly by direction (higher vs
        # lower). For higher: just add/subtract. For lower: same — `fi` and
        # `li` are already the *inner* indices from _fconvert_using_dates_parts.
        return MITRange(MIT(f_to, fi.value + ts), MIT(f_to, li.value - te))
    msg = (
        f"fconvert_range: conversion from {type(f_from).__name__} "
        f"to {type(f_to).__name__} not implemented."
    )
    raise NotImplementedError(msg)

fconvert_tseries

fconvert_tseries(
    arg1: FrequencyLike | Callable[..., Any],
    arg2: TSeries | FrequencyLike,
    arg3: TSeries | None = None,
    *,
    method: _AnyMethod | None = None,
    ref: Ref | None = None,
    **kwargs: Any,
) -> TSeries

Convert t to target frequency.

Two call shapes mirror Julia's two fconvert overloads:

  • fconvert_tseries(target, t, *, method=..., ref=...) — built-in aggregator / spreader. method is one of "mean" / "sum" / "min" / "max" / "point" / "begin" / "end" for lower-frequency conversions and "const" / "even" / "linear" for higher-frequency conversions.
  • fconvert_tseries(f, target, t, *, ref=..., **kwargs) — pass a custom callable. For lower-frequency conversions f must accept a single vector and return a scalar; for higher-frequency conversions it must accept (values, output_lengths) plus the keyword arguments ref=... and outrange=....

Parameters:

Name Type Description Default
arg1 FrequencyLike or Callable

Either the target frequency (built-in form) or the custom callable (three-positional form).

required
arg2 TSeries or FrequencyLike

The TSeries to convert in the built-in form, or the target frequency in the three-positional form.

required
arg3 TSeries

The TSeries to convert in the three-positional custom-callable form.

None
method str

Aggregator / spreader name. Lower-frequency: "mean" / "sum" / "min" / "max" / "point" / "begin" / "end". Higher-frequency: "const" / "even" / "linear". Defaults to "mean" for lower and "const" for higher.

None
ref ('begin', 'end')

Boundary anchor used when the source / target periods do not align exactly. Defaults to "end" (Julia's default).

"begin"
**kwargs Any

Forwarded to the custom callable in the three-positional form. For BDaily source on lower-frequency conversion, also accepts skip_all_nans / skip_holidays / holidays_map — these filter the underlying business days before aggregation via :func:tsecon._bdaily.cleanedvalues.

{}

Returns:

Type Description
TSeries

A new series at the target frequency. The input is not mutated. When target == t.frequency the input is returned unchanged (matching Julia's identity overload).

Raises:

Type Description
ValueError

Source or target is Unit (not convertible), or method is not one of the documented values for the chosen direction.

TypeError

The custom-callable form is used incorrectly, or skip_all_nans / skip_holidays / holidays_map is passed on a non-BDaily source.

NotImplementedError

Conversion direction is not yet implemented.

Examples:

Spread a Quarterly series across each quarter's three months using the "const" (repeat) method::

>>> from tsecon import qq, TSeries, Monthly, fconvert_tseries
>>> import numpy as np
>>> q = TSeries(qq(2020, 1), np.arange(1, 11, dtype=float))
>>> m = fconvert_tseries(Monthly, q, method="const")
>>> m.firstdate
2020M1
>>> m.lastdate
2022M6

Aggregate a Monthly series to Quarterly with the mean::

>>> from tsecon import mm, Quarterly
>>> series = TSeries(mm(2020, 1), np.arange(1.0, 13.0))
>>> fconvert_tseries(Quarterly, series, method="mean").values.tolist()
[2.0, 5.0, 8.0, 11.0]
Source code in src/tsecon/fconvert/_tseries.py
def fconvert_tseries(
    arg1: FrequencyLike | Callable[..., Any],
    arg2: TSeries | FrequencyLike,
    arg3: TSeries | None = None,
    *,
    method: _AnyMethod | None = None,
    ref: Ref | None = None,
    **kwargs: Any,
) -> TSeries:
    """Convert ``t`` to ``target`` frequency.

    Two call shapes mirror Julia's two ``fconvert`` overloads:

    * ``fconvert_tseries(target, t, *, method=..., ref=...)`` — built-in
      aggregator / spreader. ``method`` is one of ``"mean" / "sum" / "min" /
      "max" / "point" / "begin" / "end"`` for lower-frequency conversions and
      ``"const" / "even" / "linear"`` for higher-frequency conversions.
    * ``fconvert_tseries(f, target, t, *, ref=..., **kwargs)`` — pass a custom
      callable. For lower-frequency conversions ``f`` must accept a single
      vector and return a scalar; for higher-frequency conversions it must
      accept ``(values, output_lengths)`` plus the keyword arguments
      ``ref=...`` and ``outrange=...``.

    Parameters
    ----------
    arg1 : FrequencyLike or Callable
        Either the target frequency (built-in form) or the custom
        callable (three-positional form).
    arg2 : TSeries or FrequencyLike
        The TSeries to convert in the built-in form, or the target
        frequency in the three-positional form.
    arg3 : TSeries, optional
        The TSeries to convert in the three-positional custom-callable
        form.
    method : str, optional
        Aggregator / spreader name. Lower-frequency:
        ``"mean" / "sum" / "min" / "max" / "point" / "begin" / "end"``.
        Higher-frequency: ``"const" / "even" / "linear"``. Defaults
        to ``"mean"`` for lower and ``"const"`` for higher.
    ref : {"begin", "end"}, optional
        Boundary anchor used when the source / target periods do not
        align exactly. Defaults to ``"end"`` (Julia's default).
    **kwargs
        Forwarded to the custom callable in the three-positional form.
        For BDaily source on lower-frequency conversion, also accepts
        ``skip_all_nans`` / ``skip_holidays`` / ``holidays_map`` —
        these filter the underlying business days before aggregation
        via :func:`tsecon._bdaily.cleanedvalues`.

    Returns
    -------
    TSeries
        A new series at the ``target`` frequency. The input is not
        mutated. When ``target == t.frequency`` the input is returned
        unchanged (matching Julia's identity overload).

    Raises
    ------
    ValueError
        Source or target is ``Unit`` (not convertible), or ``method``
        is not one of the documented values for the chosen direction.
    TypeError
        The custom-callable form is used incorrectly, or
        ``skip_all_nans`` / ``skip_holidays`` / ``holidays_map`` is
        passed on a non-BDaily source.
    NotImplementedError
        Conversion direction is not yet implemented.

    Examples
    --------
    Spread a Quarterly series across each quarter's three months
    using the ``"const"`` (repeat) method::

        >>> from tsecon import qq, TSeries, Monthly, fconvert_tseries
        >>> import numpy as np
        >>> q = TSeries(qq(2020, 1), np.arange(1, 11, dtype=float))
        >>> m = fconvert_tseries(Monthly, q, method="const")
        >>> m.firstdate
        2020M1
        >>> m.lastdate
        2022M6

    Aggregate a Monthly series to Quarterly with the mean::

        >>> from tsecon import mm, Quarterly
        >>> series = TSeries(mm(2020, 1), np.arange(1.0, 13.0))
        >>> fconvert_tseries(Quarterly, series, method="mean").values.tolist()
        [2.0, 5.0, 8.0, 11.0]
    """
    target, t, f = _normalise_call(arg1, arg2, arg3)

    f_to = sanitize_frequency(target)
    f_from = t.frequency
    if f_to == f_from:
        return t  # mirror Julia's identity overload (works for Unit too)
    _reject_unit(f_to, what="target")
    _reject_unit(f_from, what="source")

    direction = _dispatch_direction(f_to, f_from)
    if direction == "higher":
        return _dispatch_higher(f_to, t, f, method=method, ref=ref, **kwargs)
    return _dispatch_lower(f_to, t, f, method=method, ref=ref, **kwargs)

strip_tseries

strip_tseries(t: TSeries) -> TSeries

Return a new :class:TSeries with leading and trailing typenan trimmed.

Mirrors Julia's Base.strip(t::TSeries). The original is unchanged. An all-typenan input returns an empty TSeries anchored at firstdate.

Source code in src/tsecon/fconvert/_helpers.py
def strip_tseries(t: TSeries) -> TSeries:
    """Return a new :class:`TSeries` with leading and trailing typenan trimmed.

    Mirrors Julia's ``Base.strip(t::TSeries)``. The original is unchanged.
    An all-typenan input returns an empty TSeries anchored at ``firstdate``.
    """
    rng = _valid_range(t)
    if rng.is_empty():
        return TSeries(t.firstdate, np.empty(0, dtype=t.dtype))
    return t[rng]  # type: ignore[no-any-return]

strip_tseries_inplace

strip_tseries_inplace(t: TSeries) -> TSeries

Trim leading and trailing typenan entries in place; return t.

Mirrors Julia's strip!(t::TSeries).

Source code in src/tsecon/fconvert/_helpers.py
def strip_tseries_inplace(t: TSeries) -> TSeries:
    """Trim leading and trailing typenan entries in place; return ``t``.

    Mirrors Julia's ``strip!(t::TSeries)``.
    """
    t.resize(_valid_range(t))
    return t

trim_series

trim_series(
    target: FrequencyLike,
    t: TSeries,
    *,
    direction: ExtendDirection = "both",
) -> TSeries

Trim a TSeries to ranges that align with target-frequency boundaries.

Returns a new TSeries restricted to the inner range produced by fconvert(F_from, fconvert(F_to, rangeof(t), trim=direction)).

Source code in src/tsecon/fconvert/_tseries.py
def trim_series(
    target: FrequencyLike,
    t: TSeries,
    *,
    direction: ExtendDirection = "both",
) -> TSeries:
    """Trim a TSeries to ranges that align with target-frequency boundaries.

    Returns a new TSeries restricted to the inner range produced by
    ``fconvert(F_from, fconvert(F_to, rangeof(t), trim=direction))``.
    """
    if direction not in ("both", "begin", "end"):
        msg = f"direction must be 'both', 'begin', or 'end'. Received: {direction!r}."
        raise ValueError(msg)
    f_to = sanitize_frequency(target)
    f_from = t.frequency
    _reject_unit(f_to, what="target")
    _reject_unit(f_from, what="source")
    target_range = fconvert_range(f_to, t.range, trim=direction)
    back = fconvert_range(f_from, target_range)
    return cast("TSeries", t[back])

endperiod

endperiod(x: FrequencyLike) -> int

Return the end-of-period offset for a frequency.

For Yearly / HalfYearly / Quarterly returns end_month; for Weekly returns end_day; for Monthly / Daily / BDaily / Unit returns 1.

Source code in src/tsecon/frequencies.py
def endperiod(x: FrequencyLike) -> int:
    """Return the end-of-period offset for a frequency.

    For ``Yearly`` / ``HalfYearly`` / ``Quarterly`` returns ``end_month``; for
    ``Weekly`` returns ``end_day``; for ``Monthly`` / ``Daily`` / ``BDaily`` /
    ``Unit`` returns 1.
    """
    if isinstance(x, type):
        if issubclass(x, (Yearly, HalfYearly, Quarterly, Weekly)):
            return endperiod(x())
        return 1
    if isinstance(x, (Yearly, HalfYearly, Quarterly)):
        return x.end_month
    if isinstance(x, Weekly):
        return x.end_day
    return 1

is_bdaily

is_bdaily(x: object) -> bool

Return True if x is (or is an instance of) :class:BDaily.

Source code in src/tsecon/frequencies.py
def is_bdaily(x: object) -> bool:
    """Return True if ``x`` is (or is an instance of) :class:`BDaily`."""
    return _is_kind(x, BDaily)

is_daily

is_daily(x: object) -> bool

Return True if x is (or is an instance of) :class:Daily.

Source code in src/tsecon/frequencies.py
def is_daily(x: object) -> bool:
    """Return True if ``x`` is (or is an instance of) :class:`Daily`."""
    return _is_kind(x, Daily)

is_halfyearly

is_halfyearly(x: object) -> bool

Return True if x is (or is an instance of) :class:HalfYearly.

Source code in src/tsecon/frequencies.py
def is_halfyearly(x: object) -> bool:
    """Return True if ``x`` is (or is an instance of) :class:`HalfYearly`."""
    return _is_kind(x, HalfYearly)

is_monthly

is_monthly(x: object) -> bool

Return True if x is (or is an instance of) :class:Monthly.

Source code in src/tsecon/frequencies.py
def is_monthly(x: object) -> bool:
    """Return True if ``x`` is (or is an instance of) :class:`Monthly`."""
    return _is_kind(x, Monthly)

is_quarterly

is_quarterly(x: object) -> bool

Return True if x is (or is an instance of) :class:Quarterly.

Source code in src/tsecon/frequencies.py
def is_quarterly(x: object) -> bool:
    """Return True if ``x`` is (or is an instance of) :class:`Quarterly`."""
    return _is_kind(x, Quarterly)

is_weekly

is_weekly(x: object) -> bool

Return True if x is (or is an instance of) :class:Weekly.

Source code in src/tsecon/frequencies.py
def is_weekly(x: object) -> bool:
    """Return True if ``x`` is (or is an instance of) :class:`Weekly`."""
    return _is_kind(x, Weekly)

is_yearly

is_yearly(x: object) -> bool

Return True if x is (or is an instance of) :class:Yearly.

Source code in src/tsecon/frequencies.py
def is_yearly(x: object) -> bool:
    """Return True if ``x`` is (or is an instance of) :class:`Yearly`."""
    return _is_kind(x, Yearly)

ppy

ppy(x: FrequencyLike) -> int

Return the (approximate) periods-per-year for the given frequency.

Exact for YPFrequency subclasses. Approximate for Daily (365), BDaily (260), and Weekly (52). Raises ValueError for Unit.

Source code in src/tsecon/frequencies.py
def ppy(x: FrequencyLike) -> int:
    """Return the (approximate) periods-per-year for the given frequency.

    Exact for ``YPFrequency`` subclasses. Approximate for ``Daily`` (365),
    ``BDaily`` (260), and ``Weekly`` (52). Raises ``ValueError`` for ``Unit``.
    """
    cls = x if isinstance(x, type) else type(x)
    if issubclass(cls, YPFrequency):
        return cls.periods_per_year
    default = _PPY_DEFAULTS.get(cls)
    if default is not None:
        return default
    msg = f"Frequency {cls.__name__} does not have periods per year"
    raise ValueError(msg)

prettyprint_frequency

prettyprint_frequency(x: FrequencyLike) -> str

Return a short display name for a frequency.

Default-parameter frequencies print without their parameter (Quarterly rather than Quarterly(end_month=3)); non-default ones include the parameter in braces, matching the Julia show convention.

Source code in src/tsecon/frequencies.py
def prettyprint_frequency(x: FrequencyLike) -> str:
    """Return a short display name for a frequency.

    Default-parameter frequencies print without their parameter
    (``Quarterly`` rather than ``Quarterly(end_month=3)``); non-default ones
    include the parameter in braces, matching the Julia ``show`` convention.
    """
    f = sanitize_frequency(x)
    if isinstance(f, Yearly):
        return "Yearly" if f.end_month == 12 else f"Yearly{{{f.end_month}}}"
    if isinstance(f, HalfYearly):
        return "HalfYearly" if f.end_month == 6 else f"HalfYearly{{{f.end_month}}}"
    if isinstance(f, Quarterly):
        return "Quarterly" if f.end_month == 3 else f"Quarterly{{{f.end_month}}}"
    if isinstance(f, Weekly):
        return "Weekly" if f.end_day == 7 else f"Weekly{{{f.end_day}}}"
    return type(f).__name__

sanitize_frequency

sanitize_frequency(x: FrequencyLike) -> Frequency

Return a concrete singleton frequency instance.

If x is already an instance, return it. If x is a frequency class with no required arguments (or all defaults), return its default singleton.

Source code in src/tsecon/frequencies.py
def sanitize_frequency(x: FrequencyLike) -> Frequency:
    """Return a concrete singleton frequency instance.

    If ``x`` is already an instance, return it. If ``x`` is a frequency *class*
    with no required arguments (or all defaults), return its default singleton.
    """
    if isinstance(x, Frequency):
        return x
    if isinstance(x, type) and issubclass(x, Frequency):
        return x()
    msg = f"Cannot sanitize {x!r} into a Frequency"  # type: ignore[unreachable]
    raise TypeError(msg)

lookup

lookup(
    t: TSeries,
    keys: Sequence[MIT] | Sequence[int] | ArrayLike,
) -> npt.NDArray[Any]

Return t's values at every position in keys, vectorised.

The array-of-keys companion to t[k]: while t[k] reads a single MIT- or int-keyed position, :func:lookup reads many at once with a single NumPy/Cython gather, avoiding the per-element __getitem__ dispatch tax that makes the Python loop pattern 935x slower than the Julia equivalent on the M1.5 benchmark.

Parameters:

Name Type Description Default
t TSeries

The series to gather values from. Not mutated.

required
keys sequence of MIT, sequence of int, or 1-D array-like

The positions to read. MIT keys are frequency-checked against t and translated to integer offsets into t's underlying values buffer; integer keys are interpreted positionally (matching t[i] semantics — i.e. i = 0 is t.firstdate). All keys must use the same form; mixing MITs and ints in one call is rejected.

required

Returns:

Type Description
ndarray

Freshly allocated 1-D array of the same length as keys and the same dtype as t.values. Independent of the source buffer — mutations to the result do not affect t.

Raises:

Type Description
TypeError

If any MIT key has a frequency different from t's, or if keys are mixed MIT/int, or if a key is neither MIT nor int.

IndexError

If any translated offset falls outside [0, len(t)).

Examples:

Pick four MIT positions::

>>> from tsecon import TSeries, qq, lookup
>>> import numpy as np
>>> t = TSeries(qq(2020, 1), np.arange(100.0))
>>> lookup(t, [qq(2020, 1), qq(2020, 3), qq(2022, 1), qq(2024, 4)])
array([ 0.,  2.,  8., 19.])

Or pick four integer-offset positions (positional indexing, matching t[i])::

>>> lookup(t, [0, 5, 10, 99])
array([ 0.,  5., 10., 99.])
Notes

For a single key, prefer the direct indexer t[k] — it returns a scalar without an array allocation and dispatches through the same code path. :func:lookup is for the bulk case (e.g. 10+ keys) where vectorisation amortises the per-call setup cost.

See Also

lookup_is_cython : Check whether the compiled kernel is active.

Source code in src/tsecon/indexing.py
def lookup(
    t: TSeries,
    keys: Sequence[MIT] | Sequence[int] | npt.ArrayLike,
) -> npt.NDArray[Any]:
    """Return ``t``'s values at every position in ``keys``, vectorised.

    The array-of-keys companion to ``t[k]``: while ``t[k]`` reads a
    single MIT- or int-keyed position, :func:`lookup` reads many at
    once with a single NumPy/Cython gather, avoiding the per-element
    ``__getitem__`` dispatch tax that makes the Python loop pattern
    **935x slower than the Julia equivalent** on the M1.5 benchmark.

    Parameters
    ----------
    t : TSeries
        The series to gather values from. Not mutated.
    keys : sequence of MIT, sequence of int, or 1-D array-like
        The positions to read. MIT keys are frequency-checked against
        ``t`` and translated to integer offsets into ``t``'s underlying
        values buffer; integer keys are interpreted positionally
        (matching ``t[i]`` semantics — i.e. ``i = 0`` is
        ``t.firstdate``). All keys must use the same form; mixing MITs
        and ints in one call is rejected.

    Returns
    -------
    ndarray
        Freshly allocated 1-D array of the same length as ``keys`` and
        the same dtype as ``t.values``. Independent of the source
        buffer — mutations to the result do not affect ``t``.

    Raises
    ------
    TypeError
        If any MIT key has a frequency different from ``t``'s, or if
        keys are mixed MIT/int, or if a key is neither MIT nor int.
    IndexError
        If any translated offset falls outside ``[0, len(t))``.

    Examples
    --------
    Pick four MIT positions::

        >>> from tsecon import TSeries, qq, lookup
        >>> import numpy as np
        >>> t = TSeries(qq(2020, 1), np.arange(100.0))
        >>> lookup(t, [qq(2020, 1), qq(2020, 3), qq(2022, 1), qq(2024, 4)])
        array([ 0.,  2.,  8., 19.])

    Or pick four integer-offset positions (positional indexing,
    matching ``t[i]``)::

        >>> lookup(t, [0, 5, 10, 99])
        array([ 0.,  5., 10., 99.])

    Notes
    -----
    For a single key, prefer the direct indexer ``t[k]`` — it returns
    a scalar without an array allocation and dispatches through the
    same code path. :func:`lookup` is for the *bulk* case (e.g. 10+
    keys) where vectorisation amortises the per-call setup cost.

    See Also
    --------
    lookup_is_cython : Check whether the compiled kernel is active.
    """
    keys_seq: Sequence[Any]
    if isinstance(keys, np.ndarray):
        if keys.ndim != 1:
            msg = f"lookup: keys array must be 1-D, got ndim={keys.ndim}."
            raise ValueError(msg)
        keys_seq = keys.tolist() if keys.dtype == object else keys  # type: ignore[assignment]
    else:
        keys_seq = list(keys)  # type: ignore[arg-type]

    n = len(keys_seq)
    if n == 0:
        return np.empty(0, dtype=t.values.dtype)

    # Classify by first element; the loop below verifies the rest match.
    first = keys_seq[0]
    if isinstance(first, MIT):
        if first.frequency != t.frequency:
            msg = (
                f"lookup: MIT key frequency {type(first.frequency).__name__} does not "
                f"match TSeries frequency {type(t.frequency).__name__}."
            )
            raise TypeError(msg)
        firstdate_value = t.firstdate.value
        offsets = np.empty(n, dtype=np.int64)
        for i, k in enumerate(keys_seq):
            if not isinstance(k, MIT):
                msg = (
                    f"lookup: keys must be a homogeneous sequence — got MIT at "
                    f"index 0 but {type(k).__name__} at index {i}."
                )
                raise TypeError(msg)
            if k.frequency != t.frequency:
                msg = (
                    f"lookup: MIT key at index {i} has frequency "
                    f"{type(k.frequency).__name__}, expected "
                    f"{type(t.frequency).__name__}."
                )
                raise TypeError(msg)
            offsets[i] = k.value - firstdate_value
    elif isinstance(first, (int, np.integer)) and not isinstance(first, bool):
        # Integer keys are positional offsets into t.values, matching t[i] semantics.
        try:
            offsets = np.asarray(keys_seq, dtype=np.int64)
        except (TypeError, ValueError) as exc:
            msg = (
                "lookup: integer keys must be a homogeneous sequence of ints "
                "(no MITs, no floats, no None)."
            )
            raise TypeError(msg) from exc
        if offsets.ndim != 1:
            msg = "lookup: integer keys must form a 1-D sequence."
            raise ValueError(msg)
    else:
        msg = f"lookup: keys must be MIT or int objects, got {type(first).__name__} at index 0."
        raise TypeError(msg)

    # Bounds check the offsets against t's storage.
    length = len(t.values)
    if offsets.size > 0:
        lo = int(offsets.min())
        hi = int(offsets.max())
        if lo < 0 or hi >= length:
            msg = (
                f"lookup: offsets out of range [0, {length}) — got min={lo}, max={hi}. "
                f"TSeries range is {t.range!s}."
            )
            raise IndexError(msg)

    values = t.values
    if values.dtype != np.float64:
        # Non-float64 dtypes go through NumPy's native fancy indexing — the
        # kernels are float64-specialised for the hot path; other dtypes are
        # uncommon enough that the per-call overhead doesn't justify a
        # parallel kernel family.
        return values.take(offsets)
    if _is_kernel_eligible(values):
        return _dispatch_kernel(  # type: ignore[no-any-return]
            _CYTHON_AVAILABLE, gather_cython, gather_numpy, values, offsets
        )
    # float64 but non-contiguous (e.g. a sliced ``arr[::2]`` view): the
    # Cython kernel would raise BufferError on its typed-memoryview cast,
    # so route to the NumPy reference (which uses ``np.take`` and handles
    # any stride pattern). See F11 review file.
    return gather_numpy(values, offsets)

lookup_is_cython

lookup_is_cython() -> bool

Return True iff the Cython-compiled gather kernel was importable.

Useful for tests, benchmarks, and diagnostic prints — the public :func:lookup itself is implementation-agnostic. When this returns False the same calls go through the pure-NumPy kernel in _indexing_kernels.py; behaviour is identical, only speed differs.

Source code in src/tsecon/indexing.py
def lookup_is_cython() -> bool:
    """Return True iff the Cython-compiled gather kernel was importable.

    Useful for tests, benchmarks, and diagnostic prints — the public
    :func:`lookup` itself is implementation-agnostic. When this returns
    ``False`` the same calls go through the pure-NumPy kernel in
    ``_indexing_kernels.py``; behaviour is identical, only speed
    differs.
    """
    return _CYTHON_AVAILABLE

from_pandas

from_pandas(
    obj: Series | DataFrame,
    *,
    freq: Frequency | None = None,
    wide: bool = True,
    time_col: str | None = None,
    name_col: str | None = None,
    value_col: str | None = None,
    to_workspace: bool = False,
) -> TSeries | MVTSeries | Workspace

Convert a pandas Series / DataFrame to a tsecon container.

Parameters:

Name Type Description Default
obj Series | DataFrame

A pd.Series (→ TSeries) or pd.DataFrame.

required
freq Frequency | None

Required when the time axis is a :class:pd.DatetimeIndex (or a date-typed column) that has no inferable period semantics — pandas DatetimeIndex without .freq cannot be safely auto-classified between Daily / Weekly / Monthly / Quarterly / Yearly. Optional when the index is a PeriodIndex (frequency inferred from .freqstr) or an object index of MIT (frequency inferred from the MITs themselves).

None
wide bool

When True (the default) every non-time column becomes a variable of the resulting MVTSeries. When False the DataFrame is assumed to be in long format with time_col / name_col / value_col, and is pivoted to wide before conversion.

True
time_col str | None

Optional name of the column to use as the time axis. When unset, the DataFrame index is used.

None
name_col str | None

Long-format only. Name of the column holding the per-row variable name. Required when wide=False.

None
value_col str | None

Long-format only. Name of the column holding the per-row value. Required when wide=False.

None
to_workspace bool

When True and obj is a DataFrame, return a :class:~tsecon.Workspace with one named TSeries per non-time column rather than a single MVTSeries. Useful when round-tripping a heterogeneous Workspace through pandas or when downstream code expects a Workspace. The default (False) preserves the established single-container MVTSeries return for DataFrames.

False

Returns:

Type Description
TSeries or MVTSeries or Workspace

A TSeries when the input is a Series; a Workspace when to_workspace=True; an MVTSeries otherwise.

Source code in src/tsecon/interop/pandas.py
def from_pandas(
    obj: pd.Series | pd.DataFrame,
    *,
    freq: Frequency | None = None,
    wide: bool = True,
    time_col: str | None = None,
    name_col: str | None = None,
    value_col: str | None = None,
    to_workspace: bool = False,
) -> TSeries | MVTSeries | Workspace:
    """Convert a pandas Series / DataFrame to a tsecon container.

    Parameters
    ----------
    obj
        A ``pd.Series`` (→ TSeries) or ``pd.DataFrame``.
    freq
        Required when the time axis is a :class:`pd.DatetimeIndex` (or a
        date-typed column) that has no inferable period semantics — pandas
        DatetimeIndex without ``.freq`` cannot be safely auto-classified
        between Daily / Weekly / Monthly / Quarterly / Yearly. Optional
        when the index is a ``PeriodIndex`` (frequency inferred from
        ``.freqstr``) or an object index of MIT (frequency inferred from
        the MITs themselves).
    wide
        When ``True`` (the default) every non-time column becomes a
        variable of the resulting MVTSeries. When ``False`` the DataFrame
        is assumed to be in long format with ``time_col`` / ``name_col`` /
        ``value_col``, and is pivoted to wide before conversion.
    time_col
        Optional name of the column to use as the time axis. When unset,
        the DataFrame index is used.
    name_col
        Long-format only. Name of the column holding the per-row variable
        name. Required when ``wide=False``.
    value_col
        Long-format only. Name of the column holding the per-row value.
        Required when ``wide=False``.
    to_workspace
        When ``True`` and ``obj`` is a DataFrame, return a
        :class:`~tsecon.Workspace` with one named TSeries per non-time
        column rather than a single MVTSeries. Useful when round-tripping
        a heterogeneous Workspace through pandas or when downstream code
        expects a Workspace. The default (``False``) preserves the
        established single-container MVTSeries return for DataFrames.

    Returns
    -------
    TSeries or MVTSeries or Workspace
        A TSeries when the input is a Series; a Workspace when
        ``to_workspace=True``; an MVTSeries otherwise.
    """
    pd = _require_pandas()
    if isinstance(obj, pd.Series):
        if to_workspace:
            msg = "to_workspace=True requires a DataFrame, not a Series."
            raise TypeError(msg)
        return _series_to_tseries(pd, obj, freq=freq)
    if isinstance(obj, pd.DataFrame):
        if not wide:
            obj = _long_to_wide(pd, obj, time_col=time_col, name_col=name_col, value_col=value_col)
            # After pivot, the time axis lives on the index.
            time_col = None
        if to_workspace:
            return _frame_to_workspace(pd, obj, freq=freq, time_col=time_col)
        return _frame_to_mvtseries(pd, obj, freq=freq, time_col=time_col)
    msg = f"from_pandas accepts pd.Series or pd.DataFrame; got {type(obj).__name__}."
    raise TypeError(msg)

from_polars

from_polars(
    obj: DataFrame,
    *,
    freq: Frequency | None = None,
    wide: bool = True,
    time_col: str = "time",
    name_col: str | None = None,
    value_col: str | None = None,
    to_workspace: bool = False,
) -> TSeries | MVTSeries | Workspace

Convert a polars DataFrame to a tsecon container.

Parameters:

Name Type Description Default
obj DataFrame

A :class:polars.DataFrame. Must contain time_col.

required
freq Frequency | None

Required when the time column dtype is :class:polars.Date / :class:polars.Datetime — polars carries no period semantics, so the frequency cannot be auto-inferred. For an integer time column the default is :class:~tsecon.frequencies.Unit.

None
wide bool

When True (the default) every non-time column becomes a variable. When False the DataFrame is in long format with time_col / name_col / value_col and is pivoted to wide.

True
time_col str

Name of the time column. Default "time".

'time'
name_col str | None

Long-format only.

None
value_col str | None

Long-format only. Also used to decide the resulting type: a single-value-column wide DataFrame returns a TSeries; otherwise an MVTSeries is returned.

None
to_workspace bool

When True, return a :class:~tsecon.Workspace with one named TSeries per non-time column. Preserves per-column dtypes (unlike the MVTSeries return, which forces a single shared dtype). Wins over the single-value-column TSeries shortcut.

False

Returns:

Type Description
TSeries or MVTSeries or Workspace

A Workspace when to_workspace=True; a TSeries when the wide form has a single non-time column; otherwise an MVTSeries.

Source code in src/tsecon/interop/polars.py
def from_polars(
    obj: pl.DataFrame,
    *,
    freq: Frequency | None = None,
    wide: bool = True,
    time_col: str = "time",
    name_col: str | None = None,
    value_col: str | None = None,
    to_workspace: bool = False,
) -> TSeries | MVTSeries | Workspace:
    """Convert a polars DataFrame to a tsecon container.

    Parameters
    ----------
    obj
        A :class:`polars.DataFrame`. Must contain ``time_col``.
    freq
        Required when the time column dtype is :class:`polars.Date` /
        :class:`polars.Datetime` — polars carries no period semantics, so
        the frequency cannot be auto-inferred. For an integer time column
        the default is :class:`~tsecon.frequencies.Unit`.
    wide
        When ``True`` (the default) every non-time column becomes a
        variable. When ``False`` the DataFrame is in long format with
        ``time_col`` / ``name_col`` / ``value_col`` and is pivoted to wide.
    time_col
        Name of the time column. Default ``"time"``.
    name_col
        Long-format only.
    value_col
        Long-format only. Also used to decide the resulting type: a
        single-value-column wide DataFrame returns a TSeries; otherwise
        an MVTSeries is returned.
    to_workspace
        When ``True``, return a :class:`~tsecon.Workspace` with one named
        TSeries per non-time column. Preserves per-column dtypes (unlike
        the MVTSeries return, which forces a single shared dtype). Wins
        over the single-value-column TSeries shortcut.

    Returns
    -------
    TSeries or MVTSeries or Workspace
        A Workspace when ``to_workspace=True``; a TSeries when the wide
        form has a single non-time column; otherwise an MVTSeries.
    """
    pl = _require_polars()
    if not isinstance(obj, pl.DataFrame):
        msg = f"from_polars accepts pl.DataFrame; got {type(obj).__name__}."
        raise TypeError(msg)
    if not wide:
        obj = _long_to_wide(pl, obj, time_col=time_col, name_col=name_col, value_col=value_col)
    if time_col not in obj.columns:
        msg = f"time_col={time_col!r} not found in DataFrame columns ({list(obj.columns)})."
        raise KeyError(msg)
    mits = _column_to_mits(pl, obj[time_col], freq=freq)
    data_df = obj.drop(time_col)
    if not mits:
        msg = "Cannot build a tsecon container from an empty DataFrame."
        raise ValueError(msg)
    _validate_contiguous(mits)
    rng = MITRange(mits[0], mits[-1])
    if to_workspace:
        out = Workspace()
        for col_name in data_df.columns:
            out[str(col_name)] = TSeries(rng, data_df[col_name].to_numpy())
        return out
    if data_df.width == 1:
        col_name = data_df.columns[0]
        return TSeries(rng, data_df[col_name].to_numpy())
    arr = data_df.to_numpy()
    return MVTSeries(rng, list(data_df.columns), arr)

to_pandas

to_pandas(
    obj: TSeries | MVTSeries | Workspace,
    *,
    index: IndexKind = "auto",
    date_ref: Literal["begin", "end"] = "end",
    name: str | None = None,
) -> pd.Series | pd.DataFrame

Convert a tsecon container to a pandas Series or DataFrame.

Parameters:

Name Type Description Default
obj TSeries | MVTSeries | Workspace

A :class:~tsecon.TSeries (→ pd.Series), a :class:~tsecon.MVTSeries (→ pd.DataFrame with one column per variable), or a :class:~tsecon.Workspace containing only TSeries / MVTSeries members of a common frequency (→ pd.DataFrame).

required
index IndexKind

Time-axis representation. See module docstring.

'auto'
date_ref Literal['begin', 'end']

Period-anchor when index="date": "end" (default) uses the last calendar day of each period; "begin" uses the first.

'end'
name str | None

Optional Series.name override when converting a TSeries.

None

Returns:

Type Description
Series or DataFrame

A Series for TSeries; a DataFrame otherwise.

Source code in src/tsecon/interop/pandas.py
def to_pandas(
    obj: TSeries | MVTSeries | Workspace,
    *,
    index: IndexKind = "auto",
    date_ref: Literal["begin", "end"] = "end",
    name: str | None = None,
) -> pd.Series | pd.DataFrame:
    """Convert a tsecon container to a pandas Series or DataFrame.

    Parameters
    ----------
    obj
        A :class:`~tsecon.TSeries` (→ ``pd.Series``), a
        :class:`~tsecon.MVTSeries` (→ ``pd.DataFrame`` with one column per
        variable), or a :class:`~tsecon.Workspace` containing only
        TSeries / MVTSeries members of a common frequency
        (→ ``pd.DataFrame``).
    index
        Time-axis representation. See module docstring.
    date_ref
        Period-anchor when ``index="date"``: ``"end"`` (default) uses the
        last calendar day of each period; ``"begin"`` uses the first.
    name
        Optional ``Series.name`` override when converting a TSeries.

    Returns
    -------
    pd.Series or pd.DataFrame
        A Series for TSeries; a DataFrame otherwise.
    """
    pd = _require_pandas()
    if isinstance(obj, TSeries):
        return _tseries_to_series(pd, obj, index=index, date_ref=date_ref, name=name)
    if isinstance(obj, MVTSeries):
        return _mvtseries_to_frame(pd, obj, index=index, date_ref=date_ref)
    if isinstance(obj, Workspace):
        return _workspace_to_frame(pd, obj, index=index, date_ref=date_ref)
    msg = (  # type: ignore[unreachable]
        f"to_pandas accepts TSeries, MVTSeries, or Workspace; got {type(obj).__name__}."
    )
    raise TypeError(msg)

to_polars

to_polars(
    obj: TSeries | MVTSeries | Workspace,
    *,
    time_col: str = "time",
    date_ref: Literal["begin", "end"] = "end",
    value_col: str = "value",
) -> Any

Convert a tsecon container to a polars DataFrame.

Parameters:

Name Type Description Default
obj TSeries | MVTSeries | Workspace

A :class:~tsecon.TSeries (→ DataFrame with time_col plus a single value column named value_col), an :class:~tsecon.MVTSeries (→ DataFrame with time_col plus one column per variable), or a :class:~tsecon.Workspace of TSeries / MVTSeries members of a common frequency.

required
time_col str

Name of the emitted time column. Default "time".

'time'
date_ref Literal['begin', 'end']

Period-anchor for non-Unit frequencies. "end" (default) uses the last calendar day of each period; "begin" uses the first.

'end'
value_col str

Name of the value column when converting a TSeries (ignored for MVTSeries / Workspace). Default "value".

'value'

Returns:

Type Description
DataFrame

Annotated as Any because polars's type stubs are not used in tsecon's mypy config; the runtime return is always pl.DataFrame.

Source code in src/tsecon/interop/polars.py
def to_polars(
    obj: TSeries | MVTSeries | Workspace,
    *,
    time_col: str = "time",
    date_ref: Literal["begin", "end"] = "end",
    value_col: str = "value",
) -> Any:
    """Convert a tsecon container to a polars DataFrame.

    Parameters
    ----------
    obj
        A :class:`~tsecon.TSeries` (→ DataFrame with ``time_col`` plus a
        single value column named ``value_col``), an
        :class:`~tsecon.MVTSeries` (→ DataFrame with ``time_col`` plus one
        column per variable), or a :class:`~tsecon.Workspace` of TSeries /
        MVTSeries members of a common frequency.
    time_col
        Name of the emitted time column. Default ``"time"``.
    date_ref
        Period-anchor for non-Unit frequencies. ``"end"`` (default) uses
        the last calendar day of each period; ``"begin"`` uses the first.
    value_col
        Name of the value column when converting a TSeries (ignored for
        MVTSeries / Workspace). Default ``"value"``.

    Returns
    -------
    polars.DataFrame
        Annotated as ``Any`` because polars's type stubs are not used in
        tsecon's mypy config; the runtime return is always ``pl.DataFrame``.
    """
    pl = _require_polars()
    if isinstance(obj, TSeries):
        return _tseries_to_frame(pl, obj, time_col=time_col, value_col=value_col, date_ref=date_ref)
    if isinstance(obj, MVTSeries):
        return _mvtseries_to_frame(pl, obj, time_col=time_col, date_ref=date_ref)
    if isinstance(obj, Workspace):
        return _workspace_to_frame(pl, obj, time_col=time_col, date_ref=date_ref)
    msg = (  # type: ignore[unreachable]
        f"to_polars accepts TSeries, MVTSeries, or Workspace; got {type(obj).__name__}."
    )
    raise TypeError(msg)

bdaily

bdaily(d: date, *, bias: str | None = ...) -> MIT
bdaily(d: str, *, bias: str | None = ...) -> MIT
bdaily(d: date | str, *, bias: str | None = None) -> MIT

Construct an MIT{BDaily} from a date or ISO string.

Business-daily MITs index Monday-Friday only. bias controls how a date that lands on a weekend is resolved:

  • "strict" — raise ValueError.
  • "previous" — return the preceding Friday.
  • "next" — return the following Monday.
  • "nearest" — Saturday → Friday, Sunday → Monday.

When bias is not supplied, the value falls back to the global option bdaily_creation_bias (default "strict"). Mirrors Julia's bdaily(d::Date; bias::Symbol=getoption(:bdaily_creation_bias)).

Source code in src/tsecon/mit.py
def bdaily(d: _dt.date | str, *, bias: str | None = None) -> MIT:
    """Construct an ``MIT{BDaily}`` from a date or ISO string.

    Business-daily MITs index Monday-Friday only. ``bias`` controls how a
    date that lands on a weekend is resolved:

    * ``"strict"`` — raise ``ValueError``.
    * ``"previous"`` — return the preceding Friday.
    * ``"next"`` — return the following Monday.
    * ``"nearest"`` — Saturday → Friday, Sunday → Monday.

    When ``bias`` is not supplied, the value falls back to the global option
    ``bdaily_creation_bias`` (default ``"strict"``). Mirrors Julia's
    ``bdaily(d::Date; bias::Symbol=getoption(:bdaily_creation_bias))``.
    """
    if bias is None:
        bias = getoption("bdaily_creation_bias")
    if bias not in _VALID_BDAILY_BIASES:
        msg = f"bias must be one of {sorted(_VALID_BDAILY_BIASES)}, got {bias!r}"
        raise ValueError(msg)
    date = _parse_date(d)
    ord_ = date.toordinal()
    num_weekends, rem = divmod(ord_, 7)
    # `rem` is in [0, 6]: 0 = Sunday, 1 = Monday, ..., 6 = Saturday (since
    # date.fromordinal(1) = 0001-01-01 is a Monday).
    adjustment = 0
    if rem == 0:  # Sunday
        if bias in ("next", "nearest"):
            adjustment = -1
        elif bias == "strict":
            msg = f"{date.isoformat()} is not a valid business day (Sunday)."
            raise ValueError(msg)
    elif rem == 6:  # Saturday
        if bias in ("previous", "nearest"):
            adjustment = 1
        elif bias == "strict":
            msg = f"{date.isoformat()} is not a valid business day (Saturday)."
            raise ValueError(msg)
    return MIT(BDaily(), ord_ - num_weekends * 2 - adjustment)

daily

daily(d: date) -> MIT
daily(d: str) -> MIT
daily(d: date | str) -> MIT

Construct an MIT{Daily} from a :class:datetime.date or ISO string.

Source code in src/tsecon/mit.py
def daily(d: _dt.date | str) -> MIT:
    """Construct an ``MIT{Daily}`` from a :class:`datetime.date` or ISO string."""
    date = _parse_date(d)
    return MIT(Daily(), date.toordinal())

frequency_of

frequency_of(x: object) -> Frequency

Return the frequency of an MIT, Duration, or other frequency-bearing value.

Mirrors Julia's frequencyof. Raises TypeError for values that don't carry a frequency.

Source code in src/tsecon/mit.py
def frequency_of(x: object) -> Frequency:
    """Return the frequency of an MIT, Duration, or other frequency-bearing value.

    Mirrors Julia's ``frequencyof``. Raises ``TypeError`` for values that
    don't carry a frequency.
    """
    if isinstance(x, (MIT, Duration)):
        return x.frequency
    if isinstance(x, Frequency):
        return x
    # MITRange: imported lazily to avoid circular import.
    from tsecon.mitrange import MITRange  # noqa: PLC0415

    if isinstance(x, MITRange):
        return x.frequency
    msg = f"{type(x).__name__} does not have a frequency."
    raise TypeError(msg)

mit2yp

mit2yp(x: MIT) -> tuple[int, int]

Recover (year, period) from a YP- or Daily-/BDaily-frequency MIT.

For YPFrequency subclasses this is the canonical decomposition int(mit) == N * year + (period - 1). For :class:Daily and :class:BDaily it returns the year and the 1-based index of the day within that year.

Source code in src/tsecon/mit.py
def mit2yp(x: MIT) -> tuple[int, int]:
    """Recover ``(year, period)`` from a YP- or Daily-/BDaily-frequency MIT.

    For ``YPFrequency`` subclasses this is the canonical decomposition
    ``int(mit) == N * year + (period - 1)``. For :class:`Daily` and
    :class:`BDaily` it returns the year and the 1-based index of the day
    within that year.
    """
    f = x.frequency
    if isinstance(f, YPFrequency):
        n = f.periods_per_year
        y, r = divmod(x.value, n)
        return (y, r + 1)
    if isinstance(f, Daily):
        date = _date_from_daily(x.value)
        return (date.year, date.timetuple().tm_yday)
    if isinstance(f, BDaily):
        date = _date_from_bdaily(x.value)
        y = date.year
        first = _dt.date(y, 1, 1)
        first_wd = first.isoweekday()
        days_diff = 8 - first_wd if first_wd > 5 else 0
        start = first + _dt.timedelta(days=days_diff)
        start_mit = bdaily(start).value
        return (y, x.value - start_mit + 1)
    msg = f"Value of type {type(f).__name__} cannot be represented as (year, period)."
    raise TypeError(msg)

mit_to_date

mit_to_date(m: MIT, *, ref: str = 'end') -> _dt.date

Convert an MIT to a :class:datetime.date.

For multi-day frequencies (Yearly, HalfYearly, Quarterly, Monthly, Weekly) ref controls whether the end of the period (default) or its beginning (ref="begin") is returned.

Source code in src/tsecon/mit.py
def mit_to_date(m: MIT, *, ref: str = "end") -> _dt.date:
    """Convert an MIT to a :class:`datetime.date`.

    For multi-day frequencies (Yearly, HalfYearly, Quarterly, Monthly,
    Weekly) ``ref`` controls whether the *end* of the period (default) or
    its *beginning* (``ref="begin"``) is returned.
    """
    if ref not in ("begin", "end"):
        msg = f"ref must be 'begin' or 'end', got {ref!r}"
        raise ValueError(msg)
    f = m.frequency
    v = m.value
    if isinstance(f, Daily):
        return _date_from_daily(v)
    if isinstance(f, BDaily):
        return _date_from_bdaily(v)
    if isinstance(f, Weekly):
        if ref == "begin":
            return _dt.date.fromordinal(v * 7 - 6 - (7 - f.end_day))
        return _date_from_weekly(v, end_day=f.end_day)
    if isinstance(f, Monthly):
        y, mo = divmod(v, 12)
        if ref == "begin":
            return _add_months(_dt.date(y, 1, 1), mo)
        return _add_months(_dt.date(y, 1, 1), mo + 1) - _dt.timedelta(days=1)
    if isinstance(f, Quarterly):
        em = f.end_month
        y, q = divmod(v, 4)
        if ref == "begin":
            return _add_months(_dt.date(y, 1, 1), q * 3 - (3 - em))
        return _add_months(_dt.date(y, 1, 1), (q + 1) * 3 - (3 - em)) - _dt.timedelta(days=1)
    if isinstance(f, HalfYearly):
        em = f.end_month
        y, h = divmod(v, 2)
        if ref == "begin":
            return _add_months(_dt.date(y, 1, 1), h * 6 - (6 - em))
        return _add_months(_dt.date(y, 1, 1), (h + 1) * 6 - (6 - em)) - _dt.timedelta(days=1)
    if isinstance(f, Yearly):
        em = f.end_month
        if ref == "begin":
            return _add_months(_dt.date(v, 1, 1), -(12 - em))
        return _add_months(_dt.date(v + 1, 1, 1), -(12 - em)) - _dt.timedelta(days=1)
    msg = f"Cannot convert MIT of frequency {type(f).__name__} to a date."
    raise TypeError(msg)

mm

mm(year_: int, period_: int) -> MIT

Construct an MIT at month period_ of year year_ (1..12).

Source code in src/tsecon/mit.py
def mm(year_: int, period_: int) -> MIT:
    """Construct an ``MIT`` at month ``period_`` of year ``year_`` (1..12)."""
    return MIT.from_yp(Monthly(), year_, period_)

period

period(x: MIT) -> int

Return the period component of a YP-/Daily-/BDaily-frequency MIT.

Source code in src/tsecon/mit.py
def period(x: MIT) -> int:
    """Return the period component of a YP-/Daily-/BDaily-frequency MIT."""
    return mit2yp(x)[1]

qq

qq(year_: int, period_: int) -> MIT

Construct an MIT at quarter period_ of year year_ (Q1..Q4).

Source code in src/tsecon/mit.py
def qq(year_: int, period_: int) -> MIT:
    """Construct an ``MIT`` at quarter ``period_`` of year ``year_`` (Q1..Q4)."""
    return MIT.from_yp(Quarterly(), year_, period_)

weekly

weekly(d: date, end_day: int = ...) -> MIT
weekly(d: str, end_day: int = ...) -> MIT
weekly(d: date | str, end_day: int = 7) -> MIT

Construct an MIT{Weekly(end_day)} from a date or ISO string.

The resulting MIT identifies the week (ending on the given end_day) that contains the provided date.

Source code in src/tsecon/mit.py
def weekly(d: _dt.date | str, end_day: int = 7) -> MIT:
    """Construct an ``MIT{Weekly(end_day)}`` from a date or ISO string.

    The resulting MIT identifies the week (ending on the given ``end_day``)
    that contains the provided date.
    """
    date = _parse_date(d)
    base = -(-date.toordinal() // 7)  # ceil(d / 7)
    bump = max(0, min(1, date.isoweekday() - end_day))
    return MIT(Weekly(end_day), base + bump)

weekly_from_iso

weekly_from_iso(year_: int, week_: int) -> MIT

Construct an MIT{Weekly(7)} from an ISO year + week number (1..53).

Source code in src/tsecon/mit.py
def weekly_from_iso(year_: int, week_: int) -> MIT:
    """Construct an ``MIT{Weekly(7)}`` from an ISO year + week number (1..53)."""
    if not 1 <= week_ <= 53:
        msg = f"week must be between 1 and 53 (inclusive). Received: {week_}"
        raise ValueError(msg)
    first = _dt.date(year_, 1, 1)
    week_of_first = first.isocalendar().week
    padding = 1 if week_of_first != 1 else 0
    candidate_date = first + _dt.timedelta(days=((week_ - 1) + padding) * 7)
    mit = weekly(candidate_date)
    derived = _date_from_weekly(mit.value, end_day=7)
    if derived.year != year_ and first.isocalendar().week < 52:
        msg = f"The year {year_} does not have a week {week_}."
        raise ValueError(msg)
    return mit

year

year(x: MIT) -> int

Return the year component of a YP-/Daily-/BDaily-frequency MIT.

Source code in src/tsecon/mit.py
def year(x: MIT) -> int:
    """Return the year component of a YP-/Daily-/BDaily-frequency MIT."""
    return mit2yp(x)[0]

yy

yy(year_: int, period_: int = 1) -> MIT

Construct an MIT at year year_. period_ must be 1 (default).

Source code in src/tsecon/mit.py
def yy(year_: int, period_: int = 1) -> MIT:
    """Construct an ``MIT`` at year ``year_``. ``period_`` must be 1 (default)."""
    return MIT.from_yp(Yearly(), year_, period_)

rangeof

rangeof(
    obj: object,
    *,
    drop: int = 0,
    method: Literal["intersect", "union"] = "intersect",
) -> MITRange

Return the range of obj, optionally dropping leading/trailing periods.

Mirrors Julia's rangeof(t; drop=n) and rangeof(w; method=intersect) (see TimeSeriesEcon.jl/src/workspaces.jl and src/tseries.jl). The Python equivalents — :attr:TSeries.range / :attr:MVTSeries.range properties, :meth:Workspace.rangeof, :func:rangeof_span — remain available; this function unifies them under a single kwarg-bearing call site that mirrors the Julia surface 1-to-1 and is the recommended target for line-by-line tutorial / model code ports.

Parameters:

Name Type Description Default
obj object

A :class:MITRange, :class:~tsecon.mit.MIT, :class:~tsecon.tseries.TSeries, :class:~tsecon.mvtseries.MVTSeries, or :class:~tsecon.workspace.Workspace. Anything else raises :class:TypeError naming the offending type.

required
drop int

Skip drop elements at the start (drop > 0) or at the end (drop < 0). drop == 0 returns the full range. |drop| >= len(range) returns an empty range. Mirrors Julia's drop= kwarg exactly.

0
method Literal['intersect', 'union']

"intersect" (default) → intersection of all member ranges; "union"rangeof_span semantics. Only meaningful for :class:~tsecon.workspace.Workspace; passing method="union" for any other type raises :class:TypeError to catch the likely wrong-object-type mistake.

'intersect'

Returns:

Type Description
MITRange

The (possibly shrunk) range. step is preserved for :class:MITRange inputs; :class:TSeries / :class:MVTSeries / :class:~tsecon.workspace.Workspace inputs always yield step=1.

Raises:

Type Description
TypeError

Unsupported obj type, or method="union" passed for a non-Workspace input.

ValueError

method is not one of "intersect" / "union".

Examples:

>>> import tsecon as ts
>>> a = ts.TSeries(ts.qq(2020, 1), [1.0, 2.0, 3.0, 4.0])
>>> ts.rangeof(a)
2020Q1:2020Q4
>>> ts.rangeof(a, drop=1)
2020Q2:2020Q4
>>> ts.rangeof(a, drop=-1)
2020Q1:2020Q3

The tutorial-1 @rec idiom ports line-by-line::

# Julia:  @rec rangeof(a, drop=1) a[t] = (1-ρ)*a_ss + ρ*a[t-1]
ts.rec(ts.rangeof(a, drop=1), a, lambda t: (1 - rho) * a_ss + rho * a[t - 1])

Workspace intersection vs union:

>>> w = ts.Workspace(a=a, b=ts.TSeries(ts.qq(2020, 3), [10.0, 20.0]))
>>> ts.rangeof(w)
2020Q3:2020Q4
>>> ts.rangeof(w, method="union")
2020Q1:2020Q4
Source code in src/tsecon/mitrange.py
def rangeof(
    obj: object,
    *,
    drop: int = 0,
    method: Literal["intersect", "union"] = "intersect",
) -> MITRange:
    """Return the range of ``obj``, optionally dropping leading/trailing periods.

    Mirrors Julia's ``rangeof(t; drop=n)`` and ``rangeof(w; method=intersect)``
    (see [`TimeSeriesEcon.jl/src/workspaces.jl`](https://github.com/bankofcanada/TimeSeriesEcon.jl)
    and ``src/tseries.jl``). The Python equivalents — :attr:`TSeries.range` /
    :attr:`MVTSeries.range` properties, :meth:`Workspace.rangeof`,
    :func:`rangeof_span` — remain available; this function unifies them under
    a single kwarg-bearing call site that mirrors the Julia surface 1-to-1 and
    is the recommended target for line-by-line tutorial / model code ports.

    Parameters
    ----------
    obj
        A :class:`MITRange`, :class:`~tsecon.mit.MIT`,
        :class:`~tsecon.tseries.TSeries`, :class:`~tsecon.mvtseries.MVTSeries`,
        or :class:`~tsecon.workspace.Workspace`. Anything else raises
        :class:`TypeError` naming the offending type.
    drop
        Skip ``drop`` elements at the start (``drop > 0``) or at the end
        (``drop < 0``). ``drop == 0`` returns the full range. ``|drop| >=
        len(range)`` returns an empty range. Mirrors Julia's ``drop=`` kwarg
        exactly.
    method
        ``"intersect"`` (default) → intersection of all member ranges;
        ``"union"`` → ``rangeof_span`` semantics. Only meaningful for
        :class:`~tsecon.workspace.Workspace`; passing ``method="union"`` for
        any other type raises :class:`TypeError` to catch the likely
        wrong-object-type mistake.

    Returns
    -------
    MITRange
        The (possibly shrunk) range. ``step`` is preserved for
        :class:`MITRange` inputs; :class:`TSeries` / :class:`MVTSeries` /
        :class:`~tsecon.workspace.Workspace` inputs always yield ``step=1``.

    Raises
    ------
    TypeError
        Unsupported ``obj`` type, or ``method="union"`` passed for a
        non-Workspace input.
    ValueError
        ``method`` is not one of ``"intersect"`` / ``"union"``.

    Examples
    --------
    >>> import tsecon as ts
    >>> a = ts.TSeries(ts.qq(2020, 1), [1.0, 2.0, 3.0, 4.0])
    >>> ts.rangeof(a)
    2020Q1:2020Q4
    >>> ts.rangeof(a, drop=1)
    2020Q2:2020Q4
    >>> ts.rangeof(a, drop=-1)
    2020Q1:2020Q3

    The tutorial-1 ``@rec`` idiom ports line-by-line::

        # Julia:  @rec rangeof(a, drop=1) a[t] = (1-ρ)*a_ss + ρ*a[t-1]
        ts.rec(ts.rangeof(a, drop=1), a, lambda t: (1 - rho) * a_ss + rho * a[t - 1])

    Workspace intersection vs union:

    >>> w = ts.Workspace(a=a, b=ts.TSeries(ts.qq(2020, 3), [10.0, 20.0]))
    >>> ts.rangeof(w)
    2020Q3:2020Q4
    >>> ts.rangeof(w, method="union")
    2020Q1:2020Q4
    """
    if method not in ("intersect", "union"):
        msg = f"method must be 'intersect' or 'union', got {method!r}."
        raise ValueError(msg)

    # Deferred imports break the import cycle: mitrange is a foundational
    # module imported by tseries / mvtseries / workspace, so we cannot
    # import those at module load time.
    from tsecon.mvtseries import MVTSeries  # noqa: PLC0415 — circular-import-break
    from tsecon.tseries import TSeries  # noqa: PLC0415 — circular-import-break
    from tsecon.workspace import Workspace  # noqa: PLC0415 — circular-import-break

    if isinstance(obj, Workspace):
        base = obj.rangeof(method=method)
    elif isinstance(obj, (MITRange, MIT, TSeries, MVTSeries)):
        if method != "intersect":
            msg = f"method={method!r} is only meaningful for Workspace; got {type(obj).__name__}."
            raise TypeError(msg)
        if isinstance(obj, MITRange):
            base = obj
        elif isinstance(obj, MIT):
            base = MITRange(obj, obj)
        else:
            base = obj.range
    else:
        msg = (
            f"rangeof() does not support {type(obj).__name__}; expected "
            f"MITRange, MIT, TSeries, MVTSeries, or Workspace."
        )
        raise TypeError(msg)

    return _apply_drop(base, drop)

rangeof_span

rangeof_span(*args: object) -> MITRange

Return the smallest forward MITRange covering all argument ranges.

All arguments must share a single frequency. Raises TypeError if frequencies are mixed. The returned span is always forward-stepped (step=1) regardless of the direction of the inputs.

Source code in src/tsecon/mitrange.py
def rangeof_span(*args: object) -> MITRange:
    """Return the smallest forward ``MITRange`` covering all argument ranges.

    All arguments must share a single frequency. Raises ``TypeError`` if
    frequencies are mixed. The returned span is always forward-stepped
    (``step=1``) regardless of the direction of the inputs.
    """
    chosen_lo: int | None = None
    chosen_hi: int | None = None
    chosen_freq: Frequency | None = None
    for arg in args:
        sub = _to_unitrange(arg)
        if sub is None or sub.is_empty():
            continue
        if chosen_freq is None:
            chosen_freq = sub.frequency
        elif sub.frequency != chosen_freq:
            raise TypeError(
                f"Mixing frequencies not allowed: {prettyprint_frequency(chosen_freq)} "
                f"and {prettyprint_frequency(sub.frequency)}."
            )
        sub_lo, sub_hi = _range_bounds(sub)
        chosen_lo = sub_lo if chosen_lo is None else min(chosen_lo, sub_lo)
        chosen_hi = sub_hi if chosen_hi is None else max(chosen_hi, sub_hi)
    if chosen_freq is None or chosen_lo is None or chosen_hi is None:
        # Match Julia's behavior of returning an empty `1U:0U` for no args.
        return MITRange(MIT(Unit(), 1), MIT(Unit(), 0))
    return MITRange(MIT(chosen_freq, chosen_lo), MIT(chosen_freq, chosen_hi))

hcat

hcat(*mvts: MVTSeries, **columns: Any) -> MVTSeries

Concatenate MVTSeries by columns, optionally adding more via kwargs.

All positional MVTSeries must share frequency. The output range is the span of every input MVTSeries (gaps filled with the dtype's NaN). The output dtype is the common-promoted dtype across inputs and kwargs.

Column-name collisions raise ValueError — duplicates are not allowed.

Source code in src/tsecon/mvtseries.py
def hcat(*mvts: MVTSeries, **columns: Any) -> MVTSeries:
    """Concatenate MVTSeries by columns, optionally adding more via kwargs.

    All positional MVTSeries must share frequency. The output range is the
    span of every input MVTSeries (gaps filled with the dtype's NaN). The
    output dtype is the common-promoted dtype across inputs and kwargs.

    Column-name collisions raise ``ValueError`` — duplicates are not allowed.
    """
    if not mvts:
        msg = "hcat requires at least one MVTSeries argument."
        raise ValueError(msg)
    freq = mvts[0].frequency
    for m in mvts[1:]:
        if m.frequency != freq:
            raise _mixed_freq_error(mvts[0], m)

    # Promote dtype across MVTSeries values + kwarg values that carry dtypes.
    dtypes: list[np.dtype[Any]] = [m._values.dtype for m in mvts]
    for v in columns.values():
        if isinstance(v, TSeries):
            dtypes.append(v.values.dtype)
        elif isinstance(v, np.ndarray):
            dtypes.append(v.dtype)
        elif _is_scalar_number(v):
            dtypes.append(np.asarray(v).dtype)
    out_dtype = np.result_type(*dtypes) if dtypes else np.dtype(np.float64)

    # Range = span of all MVTSeries + TSeries / MITRange kwarg values.
    span_inputs: list[MITRange] = [m.range for m in mvts if m.range]
    for v in columns.values():
        if isinstance(v, TSeries):
            span_inputs.append(v.range)
        elif isinstance(v, MITRange):
            span_inputs.append(v)
    out_range = rangeof_span(*span_inputs) if span_inputs else mvts[0].range

    # Collect ordered (name, source) pairs.
    out_names: list[str] = []
    out_values = np.full((len(out_range), 0), typenan(out_dtype), dtype=out_dtype)
    seen: set[str] = set()

    def _append(name: str, col_data: np.ndarray) -> None:
        nonlocal out_values
        if name in seen:
            msg = f"hcat: duplicate column name {name!r}."
            raise ValueError(msg)
        seen.add(name)
        out_names.append(name)
        out_values = np.hstack([out_values, col_data.reshape(-1, 1).astype(out_dtype, copy=False)])

    for m in mvts:
        for name, anchor in m._columns.items():
            full = np.full(len(out_range), typenan(out_dtype), dtype=out_dtype)
            row_off = m._firstdate.value - out_range.start.value
            full[row_off : row_off + m._values.shape[0]] = anchor.values
            _append(name, full)

    for name, v in columns.items():
        full = np.full(len(out_range), typenan(out_dtype), dtype=out_dtype)
        if isinstance(v, TSeries):
            if v.frequency != freq:
                raise _mixed_freq_error(freq, v.frequency)
            lo = max(out_range.start.value, v.firstdate.value)
            hi = min(out_range.stop.value, v.lastdate.value)
            if lo <= hi:
                rng_off = lo - out_range.start.value
                src_off = lo - v.firstdate.value
                n = hi - lo + 1
                full[rng_off : rng_off + n] = v.values[src_off : src_off + n]
        elif _is_scalar_number(v):
            full[:] = v
        else:
            arr = np.asarray(v)
            if arr.ndim != 1 or arr.shape[0] != len(out_range):
                msg = (
                    f"hcat: kwarg {name!r} vector length {arr.shape[0]} does not "
                    f"match output range length {len(out_range)}."
                )
                raise ValueError(msg)
            full[:] = arr
        _append(name, full)

    return MVTSeries(out_range.start, out_names, out_values)

rename_columns_inplace

rename_columns_inplace(
    mvts: MVTSeries,
    new_names_or_map_or_func: list[str]
    | tuple[str, ...]
    | Mapping[str, str]
    | Callable[[str], str]
    | None = None,
    *,
    replace: tuple[str, str]
    | list[tuple[str, str]]
    | None = None,
    prefix: str | None = None,
    suffix: str | None = None,
) -> MVTSeries

Rename the columns of mvts in place. Mirrors Julia rename_columns!.

Parameters:

Name Type Description Default
mvts MVTSeries

The MVTSeries to mutate. Returned for chaining.

required
new_names_or_map_or_func list[str] | tuple[str, ...] | Mapping[str, str] | Callable[[str], str] | None

One of:

  • a list / tuple of strings — full replacement, length must match;
  • a mapping old -> new — partial rename (missing keys keep their name);
  • a callable f(old) -> new — applied to every column name;
  • None — use the kwargs replace / prefix / suffix.
None
replace tuple[str, str] | list[tuple[str, str]] | None

(old, new) pair, or a list of such pairs, applied as str.replace against each column name in order. Cannot be combined with the positional new_names or mapping forms.

None
prefix str | None

String prepended to each column name (after replace).

None
suffix str | None

String appended to each column name (after replace).

None

Returns:

Type Description
MVTSeries

The same mvts instance, with its column dictionary rebuilt.

Source code in src/tsecon/mvtseries.py
def rename_columns_inplace(
    mvts: MVTSeries,
    new_names_or_map_or_func: list[str]
    | tuple[str, ...]
    | Mapping[str, str]
    | Callable[[str], str]
    | None = None,
    *,
    replace: tuple[str, str] | list[tuple[str, str]] | None = None,
    prefix: str | None = None,
    suffix: str | None = None,
) -> MVTSeries:
    """Rename the columns of ``mvts`` in place. Mirrors Julia ``rename_columns!``.

    Parameters
    ----------
    mvts
        The MVTSeries to mutate. Returned for chaining.
    new_names_or_map_or_func
        One of:

        * a list / tuple of strings — full replacement, length must match;
        * a mapping ``old -> new`` — partial rename (missing keys keep their name);
        * a callable ``f(old) -> new`` — applied to every column name;
        * ``None`` — use the kwargs ``replace`` / ``prefix`` / ``suffix``.
    replace
        ``(old, new)`` pair, or a list of such pairs, applied as
        ``str.replace`` against each column name in order. Cannot be combined
        with the positional ``new_names`` or mapping forms.
    prefix
        String prepended to each column name (after ``replace``).
    suffix
        String appended to each column name (after ``replace``).

    Returns
    -------
    MVTSeries
        The same ``mvts`` instance, with its column dictionary rebuilt.
    """
    arg = new_names_or_map_or_func
    if arg is None and replace is None and prefix is None and suffix is None:
        msg = (
            "rename_columns_inplace requires one of: new names, mapping, function, "
            "or keyword(s) replace / prefix / suffix."
        )
        raise ValueError(msg)
    if arg is not None and (replace is not None or prefix is not None or suffix is not None):
        msg = "Cannot combine positional rename argument with keyword forms."
        raise ValueError(msg)

    old_names = list(mvts._columns.keys())
    if arg is None:
        rename_fn = _rename_fn_from_kwargs(replace=replace, prefix=prefix, suffix=suffix)
        new_names = [rename_fn(n) for n in old_names]
    elif callable(arg) and not isinstance(arg, Mapping):
        new_names = [arg(n) for n in old_names]
    elif isinstance(arg, Mapping):
        new_names = [arg.get(n, n) for n in old_names]
    else:
        seq = list(arg)
        if len(seq) != len(old_names):
            msg = f"rename_columns_inplace: expected {len(old_names)} new names, got {len(seq)}."
            raise ValueError(msg)
        new_names = [str(n) for n in seq]

    if len(set(new_names)) != len(new_names):
        msg = f"rename_columns_inplace produced duplicate column names: {new_names!r}."
        raise ValueError(msg)
    # Rebuild the column dict, reusing the existing TSeries anchors (so column
    # views — which hold strided slices of mvts._values — keep working).
    new_columns: dict[str, TSeries] = {}
    for new_name, series in zip(new_names, mvts._columns.values(), strict=True):
        new_columns[new_name] = series
    mvts._columns = new_columns
    return mvts

vcat

vcat(
    mvts: MVTSeries, *blocks: _ArrayLike | MVTSeries
) -> MVTSeries

Append row blocks (matrices, MVTSeries, or 2-D arrays) below mvts.

Result keeps mvts.firstdate and column names; additional rows are appended in order. Column count must match.

Source code in src/tsecon/mvtseries.py
def vcat(mvts: MVTSeries, *blocks: _ArrayLike | MVTSeries) -> MVTSeries:
    """Append row blocks (matrices, MVTSeries, or 2-D arrays) below ``mvts``.

    Result keeps ``mvts.firstdate`` and column names; additional rows are
    appended in order. Column count must match.
    """
    pieces: list[np.ndarray] = [mvts._values]
    ncols = mvts._values.shape[1]
    for b in blocks:
        if isinstance(b, MVTSeries):
            arr = b._values
        else:
            arr = np.asarray(b)
            if arr.ndim == 1:
                if ncols != 1:
                    msg = (
                        f"vcat: 1-D block (length {arr.shape[0]}) cannot be appended to "
                        f"MVTSeries with {ncols} columns."
                    )
                    raise ValueError(msg)
                arr = arr.reshape(-1, 1)
        if arr.shape[1] != ncols:
            msg = f"vcat: block has {arr.shape[1]} columns, expected {ncols}."
            raise ValueError(msg)
        pieces.append(arr)
    out = np.vstack(pieces)
    return MVTSeries(mvts._firstdate, list(mvts._columns.keys()), out)

available_backends

available_backends() -> tuple[
    Literal["matplotlib", "plotly"], ...
]

Return the names of installed plotting backends in preference order.

Source code in src/tsecon/plotting/__init__.py
def available_backends() -> tuple[Literal["matplotlib", "plotly"], ...]:
    """Return the names of installed plotting backends in preference order."""
    return tuple(name for name in _PREFERENCE_ORDER if _is_installed(name))

plot

plot(
    *series: TSeries | MVTSeries,
    backend: BackendName = "auto",
    trange: MITRange | None = None,
    mit_loc: MitLoc = "left",
    label: str | Sequence[str] | None = None,
    vars: Sequence[str | tuple[str, str]] | None = None,
    title: str | None = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    legend: bool = True,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,
) -> Any

Plot one or more TSeries (single-axes) or MVTSeries (panel-grid) datasets.

Parameters:

Name Type Description Default
*series TSeries or MVTSeries

One or more series of a single kind — mixing TSeries and MVTSeries in one call raises :class:TypeError, matching the Julia recipe arity.

()
backend ('auto', 'matplotlib', 'plotly')

"auto" (default) picks the first installed backend in the order matplotlib → plotly.

"auto"
trange MITRange

Limit the rendered window to this range. All series must share a frequency.

None
mit_loc ('left', 'middle', 'right')

Where in each period interval the value's x-coordinate sits. For Daily / BDaily / Weekly the position is fixed at the period date and mit_loc is ignored.

"left"
label str or sequence of str

One label per series (TSeries) or per dataset (MVTSeries).

None
vars sequence of str or (str, str)

For MVTSeries: select / order the variables shown. Each item is either a column name or a (name, subplot_title) pair. Capped at 10 entries.

None
title str

Standard chart annotations. For panel plots, title is the super-title; xlabel / ylabel apply to every subplot.

None
xlabel str

Standard chart annotations. For panel plots, title is the super-title; xlabel / ylabel apply to every subplot.

None
ylabel str

Standard chart annotations. For panel plots, title is the super-title; xlabel / ylabel apply to every subplot.

None
legend bool

Whether to draw a legend.

True
figsize (float, float)

Matplotlib figure size (inches). The plotly backend interprets this as pixels at 100 DPI.

None

Returns:

Type Description
Figure or Figure

The backend's native figure object.

Source code in src/tsecon/plotting/__init__.py
def plot(
    *series: TSeries | MVTSeries,
    backend: BackendName = "auto",
    trange: MITRange | None = None,
    mit_loc: MitLoc = "left",
    label: str | Sequence[str] | None = None,
    vars: Sequence[str | tuple[str, str]] | None = None,  # matches Julia kwarg name
    title: str | None = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    legend: bool = True,
    figsize: tuple[float, float] | None = None,
    **kwargs: Any,
) -> Any:
    """Plot one or more TSeries (single-axes) or MVTSeries (panel-grid) datasets.

    Parameters
    ----------
    *series : TSeries or MVTSeries
        One or more series of a single kind — mixing TSeries and MVTSeries in
        one call raises :class:`TypeError`, matching the Julia recipe arity.
    backend : {"auto", "matplotlib", "plotly"}
        ``"auto"`` (default) picks the first installed backend in the order
        matplotlib → plotly.
    trange : MITRange, optional
        Limit the rendered window to this range. All series must share a
        frequency.
    mit_loc : {"left", "middle", "right"}
        Where in each period interval the value's x-coordinate sits. For
        Daily / BDaily / Weekly the position is fixed at the period date
        and ``mit_loc`` is ignored.
    label : str or sequence of str, optional
        One label per series (TSeries) or per dataset (MVTSeries).
    vars : sequence of str or (str, str), optional
        For MVTSeries: select / order the variables shown. Each item is
        either a column name or a ``(name, subplot_title)`` pair. Capped at
        10 entries.
    title, xlabel, ylabel : str, optional
        Standard chart annotations. For panel plots, ``title`` is the
        super-title; ``xlabel`` / ``ylabel`` apply to every subplot.
    legend : bool
        Whether to draw a legend.
    figsize : (float, float), optional
        Matplotlib figure size (inches). The plotly backend interprets
        this as pixels at 100 DPI.

    Returns
    -------
    matplotlib.figure.Figure or plotly.graph_objects.Figure
        The backend's native figure object.
    """
    if not series:
        msg = "tsecon.plot() requires at least one TSeries or MVTSeries argument"
        raise TypeError(msg)
    kind = classify_inputs(series)
    backend_name = resolve_backend(backend)
    backend_mod = importlib.import_module(f"tsecon.plotting._{backend_name}")
    if kind == "tseries":
        if vars is not None:
            msg = "vars= is only valid for MVTSeries plotting, not TSeries"
            raise TypeError(msg)
        return backend_mod.plot_tseries_many(
            list(series),
            trange=trange,
            mit_loc=mit_loc,
            label=label,
            title=title,
            xlabel=xlabel,
            ylabel=ylabel,
            legend=legend,
            figsize=figsize,
            **kwargs,
        )
    return backend_mod.plot_mvtseries_panel(
        list(series),
        trange=trange,
        mit_loc=mit_loc,
        label=label,
        vars=vars,
        title=title,
        xlabel=xlabel,
        ylabel=ylabel,
        legend=legend,
        figsize=figsize,
        **kwargs,
    )

resolve_backend

resolve_backend(
    backend: BackendName,
) -> Literal["matplotlib", "plotly"]

Resolve backend="auto" to the first available backend.

Raises :class:BackendNotAvailableError if the requested backend (or auto with no backends installed) is not available, with a pip-extras install hint.

Source code in src/tsecon/plotting/__init__.py
def resolve_backend(backend: BackendName) -> Literal["matplotlib", "plotly"]:
    """Resolve ``backend="auto"`` to the first available backend.

    Raises :class:`BackendNotAvailableError` if the requested backend (or
    ``auto`` with no backends installed) is not available, with a
    pip-extras install hint.
    """
    if backend == "auto":
        for name in _PREFERENCE_ORDER:
            if _is_installed(name):
                return name
        msg = (
            "No plotting backend is installed. Install one of:\n"
            "  pip install 'TimeSeriesEconPy[matplotlib]'   # static figures\n"
            "  pip install 'TimeSeriesEconPy[plotly]'       # interactive notebooks\n"
            "  pip install 'TimeSeriesEconPy[all]'          # both"
        )
        raise BackendNotAvailableError(msg)
    if backend not in _PREFERENCE_ORDER:
        msg = f"Unknown backend {backend!r}. Expected one of 'auto', 'matplotlib', 'plotly'."
        raise ValueError(msg)
    if not _is_installed(backend):
        msg = (
            f"Plotting backend {backend!r} is not installed. Install it with:\n"
            f"  pip install 'TimeSeriesEconPy[{backend}]'"
        )
        raise BackendNotAvailableError(msg)
    return backend

rec

rec(
    rng: MITRange, target: TSeries, fn: Callable[[MIT], Any]
) -> None

Compute target[t] = fn(t) for each t in rng, in order.

The Python equivalent of TimeSeriesEcon.jl's @rec macro. Each iteration's write is committed before the next iteration runs, so fn may reference target[t - k] (or any other series) freely: by the time step t reads target[t - k], that period has already been written by a prior step (assuming k >= 1 and the recurrence is well-defined).

The target is mutated in place. Assignment to an MIT outside target.range extends the storage via the TSeries auto-resize rule, matching the Julia @rec behaviour.

Parameters:

Name Type Description Default
rng MITRange

The range to iterate over. Its frequency must match target's frequency.

required
target TSeries

The series being computed. Mutated in place.

required
fn Callable[[MIT], scalar]

Returns the value to assign at each step. Typically a lambda closing over target (and any other series the recurrence references).

required

Raises:

Type Description
TypeError

If rng.frequency does not match target.frequency.

Examples:

Fibonacci over a Unit range::

>>> from tsecon import MIT, MITRange, TSeries, rec
>>> from tsecon.frequencies import Unit
>>> import numpy as np
>>> s = TSeries(MIT(Unit(), 1), np.array([1.0, 1.0]))
>>> rec(MITRange(MIT(Unit(), 3), MIT(Unit(), 10)), s,
...     lambda t: s[t - 1] + s[t - 2])
>>> [float(s[MIT(Unit(), i)]) for i in (1, 5, 10)]
[1.0, 5.0, 55.0]

AR(1) over a quarterly range, reading from a second series::

rec(rng, consumption,
    lambda t: beta * consumption[t - 1] + (1 - beta) * income[t])

Backcasting over a reversed range (Julia's @rec t=10U:-1:1U s[t] = s[t+1] - g)::

>>> s = TSeries(MIT(Unit(), 1), np.zeros(10))
>>> s[MIT(Unit(), 10)] = 20.0
>>> rec(MITRange(MIT(Unit(), 9), MIT(Unit(), 1), step=-1), s,
...     lambda t: s[t + 1] - 2.0)
>>> s.values.tolist()
[2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0]
Source code in src/tsecon/recursive.py
def rec(
    rng: MITRange,
    target: TSeries,
    fn: Callable[[MIT], Any],
) -> None:
    """Compute ``target[t] = fn(t)`` for each ``t`` in ``rng``, in order.

    The Python equivalent of TimeSeriesEcon.jl's ``@rec`` macro. Each
    iteration's write is committed before the next iteration runs, so
    ``fn`` may reference ``target[t - k]`` (or any other series) freely:
    by the time step ``t`` reads ``target[t - k]``, that period has
    already been written by a prior step (assuming ``k >= 1`` and the
    recurrence is well-defined).

    The ``target`` is mutated in place. Assignment to an MIT outside
    ``target.range`` extends the storage via the TSeries auto-resize
    rule, matching the Julia ``@rec`` behaviour.

    Parameters
    ----------
    rng : MITRange
        The range to iterate over. Its frequency must match
        ``target``'s frequency.
    target : TSeries
        The series being computed. Mutated in place.
    fn : Callable[[MIT], scalar]
        Returns the value to assign at each step. Typically a lambda
        closing over ``target`` (and any other series the recurrence
        references).

    Raises
    ------
    TypeError
        If ``rng.frequency`` does not match ``target.frequency``.

    Examples
    --------
    Fibonacci over a Unit range::

        >>> from tsecon import MIT, MITRange, TSeries, rec
        >>> from tsecon.frequencies import Unit
        >>> import numpy as np
        >>> s = TSeries(MIT(Unit(), 1), np.array([1.0, 1.0]))
        >>> rec(MITRange(MIT(Unit(), 3), MIT(Unit(), 10)), s,
        ...     lambda t: s[t - 1] + s[t - 2])
        >>> [float(s[MIT(Unit(), i)]) for i in (1, 5, 10)]
        [1.0, 5.0, 55.0]

    AR(1) over a quarterly range, reading from a second series::

        rec(rng, consumption,
            lambda t: beta * consumption[t - 1] + (1 - beta) * income[t])

    Backcasting over a reversed range (Julia's
    ``@rec t=10U:-1:1U s[t] = s[t+1] - g``)::

        >>> s = TSeries(MIT(Unit(), 1), np.zeros(10))
        >>> s[MIT(Unit(), 10)] = 20.0
        >>> rec(MITRange(MIT(Unit(), 9), MIT(Unit(), 1), step=-1), s,
        ...     lambda t: s[t + 1] - 2.0)
        >>> s.values.tolist()
        [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0]
    """
    if rng.frequency != target.frequency:
        msg = (
            f"rec: range frequency {type(rng.frequency).__name__} does not match "
            f"target frequency {type(target.frequency).__name__}."
        )
        raise TypeError(msg)
    for t in rng:
        target[t] = fn(t)

rec_linear

rec_linear(
    target: TSeries,
    coeffs: ArrayLike,
    lags: ArrayLike,
    rng: MITRange,
) -> None

Compute a linear-combination recurrence over rng in place.

Specialised closed-form sibling of :func:rec for the common case where each step's value is a fixed linear combination of earlier- written values of the same series. Covers Fibonacci, AR(p), arbitrary lag polynomials — the recurrences that account for ~80% of pipeline workloads per the M1 benchmark.

The body of the loop is::

for t in rng:
    target[t] = sum(coeffs[k] * target[t - lags[k]] for k in range(len(coeffs)))

Both forward (rng.step == +1) and backward (rng.step == -1) iteration are supported. The contract on lags matches the direction so that every read references an already-written or initial-condition position:

  • Forward (rng.step == +1, walking from rng.first up to rng.last): all lags[k] >= 1; target must contain valid values for every rng.first - lags[k] position before the call (the initial conditions at the start).
  • Backward (rng.step == -1, walking from rng.first down to rng.last): all lags[k] <= -1; target must contain valid values for every rng.first - lags[k] position (= rng.first + |lags[k]|, the initial conditions at the end — this is backcasting).

target is resized in place to cover rng if needed (NaN padding for new positions inside rng is overwritten by the recurrence; positions outside rng keep whatever they had, which is where the initial conditions live).

Dispatch

When the Cython extension tsecon._rec_kernels_cy is importable (the typical wheel install), this function delegates to the compiled :func:rec_linear_cython kernel — same contract, much faster. Without the extension, the call goes through :func:rec_linear_numpy, the pure-Python reference. Use :func:rec_linear_is_cython to check which path is active.

Notes

rec_linear is the closed-form pure-linear-AR(p) form: target[t] = Σ_k coeffs[k] * target[t - lags[k]] for nonzero lags. It deliberately has no constant term and no exogenous reads — that narrowing is what enables the Cython kernel path. The two common near-misses:

  • target[t] = target[t-1] + c (random walk / cumulative sum with constant drift) is not rec_linear territory; use :func:tsecon.undiff instead.
  • target[t] = β * target[t-1] + γ * y[t] (AR with exogenous reads from another series) is not rec_linear territory either; use the general :func:rec with a lambda closing over both target and y.

Both near-misses raise an informative ValueError at the call site rather than producing silent garbage — the first via the min(lags) >= 1 contract (lags=[0] for the constant-drift misread), the second by virtue of the closed-form signature accepting only coeffs × lags (no second series). The general :func:rec retains the readable nonlinear / multi-series escape hatch; pick it whenever the recurrence body does not fit the linear-AR(p) shape.

Parameters:

Name Type Description Default
target TSeries

The series being computed. Must have float64 dtype. Mutated in place. Initial conditions for every lagged read must already be present; positions inside rng are (re)written.

required
coeffs array-like of float

Recurrence weights. Will be coerced to a 1-D float64 array.

required
lags array-like of int

Nonzero lag offsets, one per coefficient. Sign must match rng.step: all >= 1 for a forward range, all <= -1 for a backward range. Coerced to a 1-D int64 array.

required
rng MITRange

The range to compute, frequency-matched to target. step must be +1 (forward) or -1 (backward).

required

Raises:

Type Description
TypeError

If rng.frequency does not match target.frequency, or if target.dtype is not float64. Each message names the offending type / dtype.

ValueError

If rng.step is not ±1; if coeffs or lags is not 1-D; if len(coeffs) != len(lags); if coeffs / lags is empty; if any lags[k] has the wrong sign for rng.step (forward requires >= 1, backward requires <= -1); or if target does not contain the initial-condition positions rng.first - lags[k] for every k. Each message names the offending value (shape, lag, MIT). The forward lags[k] < 1 branch additionally points at :func:tsecon.undiff and :func:rec for the two common near-misses (constant drift and exogenous reads); see the Notes section above.

Examples:

Fibonacci over a Unit range::

>>> import numpy as np
>>> from tsecon import MIT, MITRange, TSeries, rec_linear
>>> from tsecon.frequencies import Unit
>>> s = TSeries(MIT(Unit(), 1), np.array([1.0, 1.0, 0.0, 0.0, 0.0]))
>>> rec_linear(s, [1.0, 1.0], [1, 2],
...            MITRange(MIT(Unit(), 3), MIT(Unit(), 5)))
>>> s.values.tolist()
[1.0, 1.0, 2.0, 3.0, 5.0]

AR(2) recurrence over quarterly data::

rec_linear(consumption, [0.5, 0.3], [1, 2], rng)

Backcasting via a reversed range (Julia's @rec t=10U:-1:1U s[t] = s[t+1] - g with g = 0 constant drift becomes s[t] = s[t+1])::

rec_linear(s, [1.0], [-1],
           MITRange(MIT(Unit(), 10), MIT(Unit(), 1), step=-1))
See Also

rec : General higher-order form target[t] = fn(t). Use it when the recurrence is nonlinear or reads from series other than target. tsecon.undiff : Inverse of :func:tsecon.diff. Use it for target[t] = target[t-1] + c (random walk with constant drift) — the most common near-miss for rec_linear.

Source code in src/tsecon/recursive.py
def rec_linear(
    target: TSeries,
    coeffs: npt.ArrayLike,
    lags: npt.ArrayLike,
    rng: MITRange,
) -> None:
    """Compute a linear-combination recurrence over ``rng`` in place.

    Specialised closed-form sibling of :func:`rec` for the common case
    where each step's value is a fixed linear combination of *earlier-
    written* values of the *same* series. Covers Fibonacci, AR(p),
    arbitrary lag polynomials — the recurrences that account for ~80%
    of pipeline workloads per the M1 benchmark.

    The body of the loop is::

        for t in rng:
            target[t] = sum(coeffs[k] * target[t - lags[k]] for k in range(len(coeffs)))

    Both forward (``rng.step == +1``) and backward (``rng.step == -1``)
    iteration are supported. The contract on ``lags`` matches the
    direction so that every read references an already-written or
    initial-condition position:

    * **Forward** (``rng.step == +1``, walking from ``rng.first`` up to
      ``rng.last``): all ``lags[k] >= 1``; ``target`` must contain
      valid values for every ``rng.first - lags[k]`` position before
      the call (the *initial conditions* at the start).
    * **Backward** (``rng.step == -1``, walking from ``rng.first`` down
      to ``rng.last``): all ``lags[k] <= -1``; ``target`` must contain
      valid values for every ``rng.first - lags[k]`` position
      (= ``rng.first + |lags[k]|``, the initial conditions at the end —
      this is *backcasting*).

    ``target`` is resized in place to cover ``rng`` if needed (NaN
    padding for new positions inside ``rng`` is overwritten by the
    recurrence; positions outside ``rng`` keep whatever they had,
    which is where the initial conditions live).

    Dispatch
    --------
    When the Cython extension ``tsecon._rec_kernels_cy`` is importable
    (the typical wheel install), this function delegates to the
    compiled :func:`rec_linear_cython` kernel — same contract,
    much faster. Without the extension, the call goes through
    :func:`rec_linear_numpy`, the pure-Python reference. Use
    :func:`rec_linear_is_cython` to check which path is active.

    Notes
    -----
    ``rec_linear`` is the closed-form pure-linear-AR(p) form:
    ``target[t] = Σ_k coeffs[k] * target[t - lags[k]]`` for nonzero
    lags. It deliberately has **no constant term and no exogenous
    reads** — that narrowing is what enables the Cython kernel path.
    The two common near-misses:

    * ``target[t] = target[t-1] + c`` (random walk / cumulative sum
      with constant drift) is *not* ``rec_linear`` territory; use
      :func:`tsecon.undiff` instead.
    * ``target[t] = β * target[t-1] + γ * y[t]`` (AR with exogenous
      reads from another series) is *not* ``rec_linear`` territory
      either; use the general :func:`rec` with a lambda closing over
      both ``target`` and ``y``.

    Both near-misses raise an informative ``ValueError`` at the call
    site rather than producing silent garbage — the first via the
    ``min(lags) >= 1`` contract (``lags=[0]`` for the constant-drift
    misread), the second by virtue of the closed-form signature
    accepting only ``coeffs`` × ``lags`` (no second series). The
    general :func:`rec` retains the readable nonlinear / multi-series
    escape hatch; pick it whenever the recurrence body does not fit
    the linear-AR(p) shape.

    Parameters
    ----------
    target : TSeries
        The series being computed. Must have ``float64`` dtype. Mutated
        in place. Initial conditions for every lagged read must already
        be present; positions inside ``rng`` are (re)written.
    coeffs : array-like of float
        Recurrence weights. Will be coerced to a 1-D ``float64`` array.
    lags : array-like of int
        Nonzero lag offsets, one per coefficient. Sign must match
        ``rng.step``: all ``>= 1`` for a forward range, all ``<= -1``
        for a backward range. Coerced to a 1-D ``int64`` array.
    rng : MITRange
        The range to compute, frequency-matched to ``target``. ``step``
        must be ``+1`` (forward) or ``-1`` (backward).

    Raises
    ------
    TypeError
        If ``rng.frequency`` does not match ``target.frequency``, or if
        ``target.dtype`` is not ``float64``. Each message names the
        offending type / dtype.
    ValueError
        If ``rng.step`` is not ``±1``; if ``coeffs`` or ``lags`` is not
        1-D; if ``len(coeffs) != len(lags)``; if ``coeffs`` / ``lags``
        is empty; if any ``lags[k]`` has the wrong sign for ``rng.step``
        (forward requires ``>= 1``, backward requires ``<= -1``); or if
        ``target`` does not contain the initial-condition positions
        ``rng.first - lags[k]`` for every ``k``. Each message names the
        offending value (shape, lag, MIT). The forward ``lags[k] < 1``
        branch additionally points at :func:`tsecon.undiff` and
        :func:`rec` for the two common near-misses (constant drift and
        exogenous reads); see the *Notes* section above.

    Examples
    --------
    Fibonacci over a Unit range::

        >>> import numpy as np
        >>> from tsecon import MIT, MITRange, TSeries, rec_linear
        >>> from tsecon.frequencies import Unit
        >>> s = TSeries(MIT(Unit(), 1), np.array([1.0, 1.0, 0.0, 0.0, 0.0]))
        >>> rec_linear(s, [1.0, 1.0], [1, 2],
        ...            MITRange(MIT(Unit(), 3), MIT(Unit(), 5)))
        >>> s.values.tolist()
        [1.0, 1.0, 2.0, 3.0, 5.0]

    AR(2) recurrence over quarterly data::

        rec_linear(consumption, [0.5, 0.3], [1, 2], rng)

    Backcasting via a reversed range (Julia's
    ``@rec t=10U:-1:1U s[t] = s[t+1] - g`` with ``g = 0`` constant
    drift becomes ``s[t] = s[t+1]``)::

        rec_linear(s, [1.0], [-1],
                   MITRange(MIT(Unit(), 10), MIT(Unit(), 1), step=-1))

    See Also
    --------
    rec : General higher-order form ``target[t] = fn(t)``. Use it when
        the recurrence is nonlinear or reads from series other than
        ``target``.
    tsecon.undiff : Inverse of :func:`tsecon.diff`. Use it for
        ``target[t] = target[t-1] + c`` (random walk with constant
        drift) — the most common near-miss for ``rec_linear``.
    """
    if rng.frequency != target.frequency:
        msg = (
            f"rec_linear: range frequency {type(rng.frequency).__name__} does not "
            f"match target frequency {type(target.frequency).__name__}."
        )
        raise TypeError(msg)
    if rng.step not in (1, -1):
        msg = f"rec_linear: rng.step must be +1 (forward) or -1 (backward), got step={rng.step}."
        raise ValueError(msg)
    coeffs_arr = np.asarray(coeffs, dtype=np.float64)
    lags_arr = np.asarray(lags, dtype=np.int64)
    if coeffs_arr.ndim != 1 or lags_arr.ndim != 1:
        msg = (
            f"rec_linear: coeffs and lags must be 1-D; got "
            f"coeffs.ndim={coeffs_arr.ndim}, lags.ndim={lags_arr.ndim}."
        )
        raise ValueError(msg)
    if coeffs_arr.shape[0] != lags_arr.shape[0]:
        msg = (
            f"rec_linear: coeffs (n={coeffs_arr.shape[0]}) and lags "
            f"(n={lags_arr.shape[0]}) must have the same length."
        )
        raise ValueError(msg)
    if coeffs_arr.shape[0] == 0:
        msg = (
            f"rec_linear: coeffs/lags must be non-empty; got "
            f"coeffs.shape[0]={coeffs_arr.shape[0]}, "
            f"lags.shape[0]={lags_arr.shape[0]}."
        )
        raise ValueError(msg)
    step = int(rng.step)
    if step > 0:
        # Forward iteration reads earlier positions; lags must be >= 1.
        min_lag = int(lags_arr.min())
        if min_lag < 1:
            msg = (
                f"rec_linear: with a forward range (step=+1), all lags must be "
                f">= 1; got min lag {min_lag}. rec_linear is the closed-form "
                f"pure-linear AR(p) form (target[t] = Sum_k coeffs[k] * "
                f"target[t - lags[k]]) and has no constant term or exogenous "
                f"reads. For x[t] = x[t-1] + constant, use undiff() instead. "
                f"For x[t] = ... + y[t] (exogenous reads), use rec(rng, target, "
                f"fn) with a lambda. For backward recurrences use a reversed "
                f"range (step=-1) and negative lags."
            )
            raise ValueError(msg)
    else:
        # Backward iteration reads later positions; lags must be <= -1.
        max_lag = int(lags_arr.max())
        if max_lag > -1:
            msg = (
                f"rec_linear: with a backward range (step=-1), all lags must be "
                f"<= -1; got max lag {max_lag}. For forward recurrences use a "
                f"forward range (step=+1) and positive lags."
            )
            raise ValueError(msg)
    if target.dtype != np.float64:
        msg = (
            f"rec_linear: target.dtype must be float64 to feed the kernel, "
            f"got {target.dtype}. Construct with TSeries(..., dtype=np.float64) or "
            f"call .astype(np.float64) first."
        )
        raise TypeError(msg)
    if len(rng) == 0:
        return

    rng_first = rng.start
    # The kernel reads target[out_idx - lags[k]] at each step. The extremes:
    #   forward (step=+1, lags>0): earliest read is rng.first - max(lags).
    #   backward (step=-1, lags<0): latest read is rng.first - min(lags)
    #     (= rng.first + |min(lags)|, a position past rng.first).
    if step > 0:
        earliest_read = rng_first.value - int(lags_arr.max())
        if earliest_read < target.firstdate.value:
            earliest_mit = MIT(target.frequency, earliest_read)
            msg = (
                f"rec_linear: initial conditions missing — recurrence reads "
                f"target[{earliest_mit!s}] but target starts at {target.firstdate!s}."
            )
            raise ValueError(msg)
    else:
        latest_read = rng_first.value - int(lags_arr.min())
        if latest_read > target.lastdate.value:
            latest_mit = MIT(target.frequency, latest_read)
            msg = (
                f"rec_linear: initial conditions missing — backward recurrence "
                f"reads target[{latest_mit!s}] but target ends at {target.lastdate!s}."
            )
            raise ValueError(msg)

    # Ensure target covers rng (auto-extend mirrors `rec`'s setitem behaviour).
    target._ensure_covers(rng)
    values = target._values
    offset = rng_first.value - target.firstdate.value
    count = len(rng)

    # `target.dtype == np.float64` was validated above; `target._ensure_covers`
    # has just produced a 1-D contiguous buffer; coeffs/lags were created via
    # `np.asarray(..., dtype=...)` with explicit dtypes. The fast-path
    # contract from `_is_kernel_eligible` is satisfied unconditionally here,
    # so the dispatcher's only decision is Cython vs NumPy reference.
    _dispatch_kernel(
        _CYTHON_AVAILABLE,
        rec_linear_cython,
        rec_linear_numpy,
        values,
        offset,
        count,
        step,
        coeffs_arr,
        lags_arr,
    )

rec_linear_is_cython

rec_linear_is_cython() -> bool

Return True iff the Cython-compiled rec_linear kernel was importable.

Useful for tests, benchmarks, and diagnostic prints — the public :func:rec_linear itself is implementation-agnostic. When this returns False the same calls go through the pure-NumPy kernel in _rec_kernels.py; behaviour is identical, only speed differs.

Source code in src/tsecon/recursive.py
def rec_linear_is_cython() -> bool:
    """Return True iff the Cython-compiled rec_linear kernel was importable.

    Useful for tests, benchmarks, and diagnostic prints — the public
    :func:`rec_linear` itself is implementation-agnostic. When this
    returns ``False`` the same calls go through the pure-NumPy kernel
    in ``_rec_kernels.py``; behaviour is identical, only speed differs.
    """
    return _CYTHON_AVAILABLE

typenan

typenan(dtype: DTypeLike) -> Any

Return the not-a-number sentinel for dtype.

For floats this is NaN; for signed and unsigned integers it is iinfo(dtype).max; for booleans it is False. Mirrors TimeSeriesEcon.jl's typenan (see tseries.jl).

Source code in src/tsecon/tseries.py
def typenan(dtype: npt.DTypeLike) -> Any:
    """Return the not-a-number sentinel for ``dtype``.

    For floats this is ``NaN``; for signed and unsigned integers it is
    ``iinfo(dtype).max``; for booleans it is ``False``. Mirrors
    ``TimeSeriesEcon.jl``'s ``typenan`` (see ``tseries.jl``).
    """
    dt = np.dtype(dtype)
    if np.issubdtype(dt, np.floating):
        return np.array(np.nan, dtype=dt)[()]
    if np.issubdtype(dt, np.integer):
        return np.array(np.iinfo(dt).max, dtype=dt)[()]
    if dt == np.bool_:
        return np.bool_(False)
    msg = f"No typenan defined for dtype {dt}."
    raise TypeError(msg)

copyto

copyto(
    dst: MVTSeries,
    src: Workspace,
    *,
    verbose: bool = False,
    trange: MITRange | None = None,
) -> MVTSeries

Copy Workspace members into a pre-allocated MVTSeries in place.

Mirrors Julia's Base.copyto!(x::MVTSeries, w::AbstractWorkspace; verbose=false, trange=rangeof(x)). For each column name in dst.column_names, the matching src[name] :class:TSeries is written into the destination column over the overlap of trange and the source TSeries's range; dst._values is mutated, never replaced.

Parameters:

Name Type Description Default
dst MVTSeries

Destination MVTSeries. Its matrix buffer is written through; storage identity is preserved (id(dst._values) is unchanged on return).

required
src Workspace

Source Workspace. Only members whose names appear in dst.column_names are visited; extra Workspace entries are ignored.

required
verbose bool

When True, a single :class:UserWarning is emitted at the end of the loop listing every column that was not written because its name is absent from src. Matches Julia's end-of-loop @warn shape.

False
trange MITRange | None

Optional :class:MITRange restricting the rows to write. Defaults to dst.range. Must share dst's frequency, have step == 1, and be contained in dst.range. The actual rows written for any given column are the intersection of trange with that column's source :class:TSeries range (so a source that doesn't cover all of trange writes only the overlap, never raises).

None

Returns:

Name Type Description
dst MVTSeries

The same object passed in (enables chaining; storage identity preserved).

Raises:

Type Description
TypeError

If trange (or, by default, dst's frequency) does not match a matching source member's frequency, or if a Workspace member at a matching key is not a :class:TSeries.

ValueError

If trange has a non-unit step.

IndexError

If trange is not contained in dst.range.

Examples:

Tight model-simulation loop reusing the same buffer:

>>> import tsecon as ts
>>> buf = ts.MVTSeries(ts.MITRange(ts.qq(2020, 1), ts.qq(2024, 4)), ["a", "b"])
>>> for _ in range(100):
...     w = _step(...)               # produces a Workspace with TSeries 'a', 'b'
...     ts.copyto(buf, w)            # in-place; no MVTSeries reallocation
See Also

MVTSeries : the destination type. Workspace : the source type.

Source code in src/tsecon/workspace.py
def copyto(
    dst: MVTSeries,
    src: Workspace,
    *,
    verbose: bool = False,
    trange: MITRange | None = None,
) -> MVTSeries:
    """Copy Workspace members into a pre-allocated MVTSeries in place.

    Mirrors Julia's ``Base.copyto!(x::MVTSeries, w::AbstractWorkspace;
    verbose=false, trange=rangeof(x))``. For each column name in
    ``dst.column_names``, the matching ``src[name]`` :class:`TSeries` is
    written into the destination column over the overlap of ``trange`` and
    the source TSeries's range; ``dst._values`` is mutated, never replaced.

    Parameters
    ----------
    dst
        Destination MVTSeries. Its matrix buffer is written through; storage
        identity is preserved (``id(dst._values)`` is unchanged on return).
    src
        Source Workspace. Only members whose names appear in
        ``dst.column_names`` are visited; extra Workspace entries are ignored.
    verbose
        When True, a single :class:`UserWarning` is emitted at the end of
        the loop listing every column that was not written because its name
        is absent from ``src``. Matches Julia's end-of-loop ``@warn`` shape.
    trange
        Optional :class:`MITRange` restricting the rows to write. Defaults
        to ``dst.range``. Must share ``dst``'s frequency, have ``step == 1``,
        and be contained in ``dst.range``. The actual rows written for any
        given column are the intersection of ``trange`` with that column's
        source :class:`TSeries` range (so a source that doesn't cover all
        of ``trange`` writes only the overlap, never raises).

    Returns
    -------
    dst : MVTSeries
        The same object passed in (enables chaining; storage identity
        preserved).

    Raises
    ------
    TypeError
        If ``trange`` (or, by default, ``dst``'s frequency) does not match a
        matching source member's frequency, or if a Workspace member at a
        matching key is not a :class:`TSeries`.
    ValueError
        If ``trange`` has a non-unit step.
    IndexError
        If ``trange`` is not contained in ``dst.range``.

    Examples
    --------
    Tight model-simulation loop reusing the same buffer:

    >>> import tsecon as ts
    >>> buf = ts.MVTSeries(ts.MITRange(ts.qq(2020, 1), ts.qq(2024, 4)), ["a", "b"])
    >>> for _ in range(100):
    ...     w = _step(...)               # produces a Workspace with TSeries 'a', 'b'
    ...     ts.copyto(buf, w)            # in-place; no MVTSeries reallocation

    See Also
    --------
    MVTSeries : the destination type.
    Workspace : the source type.
    """
    if not isinstance(dst, MVTSeries):
        msg = f"copyto dst must be MVTSeries, got {type(dst).__name__}."  # type: ignore[unreachable]
        raise TypeError(msg)
    if not isinstance(src, Workspace):
        msg = f"copyto src must be Workspace, got {type(src).__name__}."  # type: ignore[unreachable]
        raise TypeError(msg)

    target_range = trange if trange is not None else dst.range
    if target_range.frequency != dst.frequency:
        msg = (
            f"copyto: trange has frequency {prettyprint_frequency(target_range.frequency)}, "
            f"expected {prettyprint_frequency(dst.frequency)}."
        )
        raise TypeError(msg)
    if target_range.step != 1:
        msg = f"copyto: trange must have step=1, got step={target_range.step}."
        raise ValueError(msg)
    if not dst.is_empty() and (
        target_range.start.value < dst.firstdate.value
        or target_range.stop.value > dst.lastdate.value
    ):
        msg = f"copyto: trange {target_range!s} is not contained in dst range {dst.range!s}."
        raise IndexError(msg)

    missing: list[str] = []
    for name in dst.column_names:
        if name not in src:
            if verbose:
                missing.append(name)
            continue
        src_val = src[name]
        if not isinstance(src_val, TSeries):
            msg = (
                f"copyto: workspace member {name!r} is {type(src_val).__name__}, expected TSeries."
            )
            raise TypeError(msg)
        if src_val.frequency != dst.frequency:
            msg = (
                f"copyto: workspace member {name!r} has frequency "
                f"{prettyprint_frequency(src_val.frequency)}, expected "
                f"{prettyprint_frequency(dst.frequency)}."
            )
            raise TypeError(msg)
        lo = max(target_range.start.value, src_val.firstdate.value)
        hi = min(target_range.stop.value, src_val.lastdate.value)
        if lo > hi:
            continue
        overlap = MITRange(MIT(dst.frequency, lo), MIT(dst.frequency, hi))
        dst[overlap, name] = src_val[overlap]

    if verbose and missing:
        warnings.warn(
            f"Variables not copied (missing from Workspace): {', '.join(missing)}",
            UserWarning,
            stacklevel=2,
        )
    return dst