Skip to content

MIT and MITRange

Moment-in-time scalars and ranges. MIT carries its frequency on the type; arithmetic between two MITs yields a Duration, while MIT + Duration (or MIT + int as a shorthand) yields a new MIT. MITRange is the inclusive analogue of Julia's first:last range.

tsecon.mit

tsecon.mit

MIT (moment-in-time) and Duration types.

Mirrors TimeSeriesEcon.jl's momentintime.jl. An :class:MIT carries a frequency and an integer value; :class:Duration is the same shape but represents a distance between two MITs of the same frequency.

  • MIT - MITDuration (same frequency required).
  • MIT - DurationMIT.
  • MIT + DurationMIT.
  • MIT + MIT raises (semantically meaningless).
  • Adding/subtracting a plain int is allowed and interpreted as a duration in the MIT's own frequency.

Constructor functions :func:qq, :func:mm, :func:yy, :func:daily, :func:bdaily, :func:weekly are the Pythonic equivalent of the Julia literal-suffix syntax 2020Q1, 2020M3, 2020Y, etc.

Equality with plain integers is not supported (unlike Julia, where MIT(Quarterly, 5) == 5 is true). The Julia idiom violates Python's __hash__/__eq__ invariant; use int(mit) or mit.value to extract the underlying integer instead.

BDailyBias module-attribute

BDailyBias = Union['str']

One of "strict", "previous", "next", "nearest".

Determines how :func:bdaily resolves a date that falls on a weekend. "strict" (the default, matching Julia) raises rather than guessing.

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)

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)

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]

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]

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)

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_)

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_)

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_)

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())

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)

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

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)

tsecon.mitrange

tsecon.mitrange

MITRange — an inclusive integer range of frequency-tagged moments in time.

Mirrors Julia's UnitRange{MIT{F}} and StepRange{MIT{F}}. A range is defined by (start, stop, step) where start and stop are :class:~tsecon.mit.MIT values of the same frequency and step is a nonzero integer number of periods (or a :class:~tsecon.mit.Duration of the same frequency). step may be negative — MITRange(10U, 1U, -1) walks backward, mirroring Julia's 10U:-1:1U. Ranges whose endpoints do not match the sign of step (e.g. MITRange(1U, 10U, -1)) are empty.

MITRange supports len, bidirectional iteration, indexing (by int or MIT), slicing, and membership testing.

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)

mitrange

mitrange(
    start: MIT, stop: MIT, step: int | Duration = 1
) -> MITRange

Functional constructor for :class:MITRange.

Mirrors Julia's start:stop and start:step:stop colon syntax. step may be an int or a :class:Duration of the same frequency.

Source code in src/tsecon/mitrange.py
def mitrange(start: MIT, stop: MIT, step: int | Duration = 1) -> MITRange:
    """Functional constructor for :class:`MITRange`.

    Mirrors Julia's ``start:stop`` and ``start:step:stop`` colon syntax.
    ``step`` may be an ``int`` or a :class:`Duration` of the same frequency.
    """
    if isinstance(step, Duration):
        if step.frequency != start.frequency:
            raise TypeError(
                f"step Duration frequency {prettyprint_frequency(step.frequency)} "
                f"does not match range frequency {prettyprint_frequency(start.frequency)}."
            )
        step_int = step.value
    else:
        step_int = int(step)
    return MITRange(start, stop, step_int)

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))