Skip to content

Frequency conversion (fconvert)

Round-trip frequency conversion across YP frequencies (Yearly / HalfYearly / Quarterly / Monthly) and calendar frequencies (Daily / BDaily / Weekly). Public surface: fconvert, fconvert_tseries, fconvert_mit, fconvert_range, fconvert_parts, extend_series, strip_tseries / strip_tseries_inplace, trim_series.

YP→YP aggregate (lower-frequency) paths route through a Cython kernel that fuses the two nested loops in aggregate_groups_numpy; introspect with fconvert_is_cython().

tsecon.fconvert

Frequency-conversion subsystem.

Mirrors TimeSeriesEcon.jl/src/fconvert/. The single user-facing entry is :func:fconvert, which dispatches on the second positional argument:

  • fconvert(target, mit, *, ref=..., round_to=...) — convert an :class:~tsecon.mit.MIT. round_to is only meaningful for BDaily targets.
  • fconvert(target, mitrange, *, trim=...) — convert an :class:~tsecon.mitrange.MITRange.
  • fconvert(target, tseries, *, method=..., ref=...) — convert a :class:~tsecon.tseries.TSeries using a built-in aggregator / spreader.
  • fconvert(f, target, tseries, *, ref=..., **kwargs) — convert a TSeries using the custom callable f.

Lower-level entries (also exported) for callers that already know the input type:

  • :func:fconvert_mit / :func:fconvert_range / :func:fconvert_tseries — the per-input-type entry points (no Julia-style multiple-dispatch overhead).
  • :func:fconvert_parts — internal triple used by the YP→YP TSeries conversion path ((period, source_month, target_month)); exposed for advanced use.

Helpers and series-shape operations:

  • :func:extend_series — pad to align with target-period boundaries.
  • :func:trim_series — restrict to the inner aligned range.
  • :func:strip_tseries / :func:strip_tseries_inplace — drop leading and trailing typenan entries.
  • :func:repeat_uneven / :func:divide_uneven / :func:linear_uneven — vector helpers used by the higher-frequency conversion methods (and available as the canonical custom-function aliases that mirror method="const" / "even" / "linear" exactly).

Currently still ⬜: the BDaily kwarg variants (skip_holidays / skip_all_nans / holidays_map) block on the options.jl port; and BDaily-source :func:extend_series needs the cleanedvalues plumbing.

divide_uneven

divide_uneven(
    x: ArrayLike, inner: ArrayLike, **_kwargs: object
) -> np.ndarray

Divide each element of x by its inner count, then repeat.

Returns a Float64 vector of length sum(inner). Mirrors the Julia divide_uneven: divide_uneven([1, 2, 4], [2, 1, 4]) == [0.5, 0.5, 2.0, 1.0, 1.0, 1.0, 1.0]. Extra keyword arguments are accepted and ignored.

Source code in src/tsecon/fconvert/_helpers.py
def divide_uneven(x: npt.ArrayLike, inner: npt.ArrayLike, **_kwargs: object) -> np.ndarray:
    """Divide each element of ``x`` by its inner count, then repeat.

    Returns a Float64 vector of length ``sum(inner)``. Mirrors the Julia
    ``divide_uneven``: ``divide_uneven([1, 2, 4], [2, 1, 4]) ==
    [0.5, 0.5, 2.0, 1.0, 1.0, 1.0, 1.0]``. Extra keyword arguments are
    accepted and ignored.
    """
    arr = np.asarray(x, dtype=np.float64)
    counts = np.asarray(inner, dtype=np.int64)
    if arr.shape[0] != counts.shape[0]:
        msg = (
            f"divide_uneven: x and inner must have the same length "
            f"({arr.shape[0]} vs {counts.shape[0]})."
        )
        raise ValueError(msg)
    return np.repeat(arr / counts, counts)

linear_uneven

linear_uneven(
    x: ArrayLike,
    output_lengths: ArrayLike,
    *,
    ref: Ref = "end",
    **_kwargs: object,
) -> np.ndarray

Linearly interpolate x across per-input output counts.

Returns a Float64 vector of length sum(output_lengths). ref="end" treats each input value as the end-point of its segment; ref="begin" treats it as the start-point. Tail-end segments extrapolate with the slope of the adjacent segment.

Source code in src/tsecon/fconvert/_helpers.py
def linear_uneven(
    x: npt.ArrayLike,
    output_lengths: npt.ArrayLike,
    *,
    ref: Ref = "end",
    **_kwargs: object,
) -> np.ndarray:
    """Linearly interpolate ``x`` across per-input output counts.

    Returns a Float64 vector of length ``sum(output_lengths)``. ``ref="end"``
    treats each input value as the end-point of its segment; ``ref="begin"``
    treats it as the start-point. Tail-end segments extrapolate with the slope
    of the adjacent segment.
    """
    if ref not in ("begin", "end"):
        msg = f"linear_uneven: ref must be 'begin' or 'end'. Received: {ref!r}."
        raise ValueError(msg)
    arr = np.asarray(x, dtype=np.float64)
    counts = np.asarray(output_lengths, dtype=np.int64)
    if arr.shape[0] != counts.shape[0]:
        msg = (
            f"linear_uneven: x and output_lengths must have the same length "
            f"({arr.shape[0]} vs {counts.shape[0]})."
        )
        raise ValueError(msg)
    out = np.empty(int(counts.sum()), dtype=np.float64)
    pos = 0
    n = arr.shape[0]
    if ref == "end":
        for i in range(n):
            length = int(counts[i])
            if i == 0:
                step = (arr[1] - arr[0]) / int(counts[1])
                vals = np.linspace(arr[0] - length * step, arr[0], length + 1)
            else:
                vals = np.linspace(arr[i - 1], arr[i], length + 1)
            out[pos : pos + length] = vals[1:]
            pos += length
    else:
        for i in range(n):
            length = int(counts[i])
            if i == n - 1:
                step = (arr[i] - arr[i - 1]) / int(counts[i - 1])
                vals = np.linspace(arr[i], arr[i] + length * step, length + 1)
            else:
                vals = np.linspace(arr[i], arr[i + 1], length + 1)
            out[pos : pos + length] = vals[:-1]
            pos += length
    return out

repeat_uneven

repeat_uneven(
    x: ArrayLike, inner: ArrayLike, **_kwargs: object
) -> np.ndarray

Repeat each element of x according to the matching count in inner.

Returns a vector of length sum(inner). Mirrors the Julia repeat_uneven: repeat_uneven([1, 2, 4], [2, 1, 4]) == [1, 1, 2, 4, 4, 4, 4]. Extra keyword arguments are accepted and ignored — required so this helper is drop-in callable from :func:tsecon.fconvert.fconvert (which always forwards ref and outrange).

Source code in src/tsecon/fconvert/_helpers.py
def repeat_uneven(x: npt.ArrayLike, inner: npt.ArrayLike, **_kwargs: object) -> np.ndarray:
    """Repeat each element of ``x`` according to the matching count in ``inner``.

    Returns a vector of length ``sum(inner)``. Mirrors the Julia
    ``repeat_uneven``: ``repeat_uneven([1, 2, 4], [2, 1, 4]) ==
    [1, 1, 2, 4, 4, 4, 4]``. Extra keyword arguments are accepted and
    ignored — required so this helper is drop-in callable from
    :func:`tsecon.fconvert.fconvert` (which always forwards ``ref`` and
    ``outrange``).
    """
    arr = np.asarray(x)
    counts = np.asarray(inner, dtype=np.int64)
    if arr.shape[0] != counts.shape[0]:
        msg = (
            f"repeat_uneven: x and inner must have the same length "
            f"({arr.shape[0]} vs {counts.shape[0]})."
        )
        raise ValueError(msg)
    return np.repeat(arr, counts)

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

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)

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

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

fconvert

fconvert(
    target: FrequencyLike,
    what: MIT,
    *,
    ref: str = "end",
    round_to: str = "current",
) -> MIT
fconvert(
    target: FrequencyLike,
    what: MITRange,
    *,
    trim: str = "both",
) -> MITRange
fconvert(
    target: FrequencyLike,
    what: TSeries,
    *,
    method: str | None = None,
    ref: str | None = None,
) -> TSeries
fconvert(
    f: Callable[..., Any],
    target: FrequencyLike,
    what: TSeries,
    *,
    ref: str | None = None,
    **kwargs: Any,
) -> TSeries
fconvert(*args: Any, **kwargs: Any) -> Any

Convert an :class:MIT, :class:MITRange, or :class:TSeries to a target frequency.

See the module-level docstring for the four call shapes. Dispatches on the second positional argument (or, when a callable is the first positional argument, the third).

Source code in src/tsecon/fconvert/__init__.py
def fconvert(*args: Any, **kwargs: Any) -> Any:
    """Convert an :class:`MIT`, :class:`MITRange`, or :class:`TSeries` to a target frequency.

    See the module-level docstring for the four call shapes. Dispatches on
    the second positional argument (or, when a callable is the first
    positional argument, the third).
    """
    if len(args) == 0:
        msg = "fconvert: missing positional arguments."
        raise TypeError(msg)
    first = args[0]
    # fconvert(f, target, t, ...)
    if callable(first) and not isinstance(first, (type, Frequency)):
        if len(args) != 3:
            msg = (
                "fconvert: when the first positional argument is a callable, exactly three "
                "positional arguments are required: (f, target, t)."
            )
            raise TypeError(msg)
        return fconvert_tseries(args[0], args[1], args[2], **kwargs)
    # fconvert(target, what, ...)
    if len(args) != 2:
        msg = (
            "fconvert: expected two positional arguments (target, what) or three "
            f"with a callable first arg. Got {len(args)}."
        )
        raise TypeError(msg)
    what = args[1]
    if isinstance(what, MIT):
        return fconvert_mit(args[0], what, **kwargs)
    if isinstance(what, MITRange):
        return fconvert_range(args[0], what, **kwargs)
    if isinstance(what, TSeries):
        return fconvert_tseries(args[0], what, **kwargs)
    msg = (
        f"fconvert: second positional argument must be MIT, MITRange, or TSeries; "
        f"got {type(what).__name__}."
    )
    raise TypeError(msg)