Skip to content

Math (shift, diff, moving, undiff)

Time-series transforms re-exported at top level: shift / lag / lead / diff / pct / apct / ytypct / moving / moving_average / moving_sum / undiff and their _inplace variants where applicable. BDaily kwargs (skip_all_nans / skip_holidays / holidays_map) are wired into shift / lag / lead / diff / pct.

tsecon._math

Time-series math helpers.

Implements shift / lag / lead / diff / pct / apct / ytypct / moving / moving_sum / moving_average / undiff / undiff_inplace, ported from TimeSeriesEcon.jl's tsmath.jl (plus Base.diff(::TSeries) and the moving / undiff block from mvtseries.jl; the latter is grouped here because both helpers work on TSeries and MVTSeries alike — only the TSeries overloads are ported in M1, with MVTSeries overloads added when MVTSeries lands).

Convention follows the Julia source: positive k produces the lead (the value at t+k is read at t), negative k produces the lag. So shift(x, +1) moves the firstdate earlier by one period (the value previously at 2020Q1 is now at 2019Q4).

In-place variants (shift_inplace / lag_inplace / lead_inplace / undiff_inplace) mirror the Julia shift! / lag! / lead! / undiff! family: they only mutate their first argument and reuse its existing buffer (resizing as needed).

BDaily skip_all_nans / skip_holidays / holidays_map keyword variants (tsmath.jl lines 111+, tseries.jl line 525+) live on shift / shift_inplace / lag / lag_inplace / lead / lead_inplace / diff / pct. They require a BDaily series and call :func:tsecon._bdaily.replace_nans_if_warranted after the firstdate shift to infill NaN entries. Passing any of these kwargs on a non-BDaily series raises :class:TypeError to mirror Julia's type-dispatch separation.

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

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

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

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)

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)

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)

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)

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)

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