Source code for macrosynergy.management.utils.frequency

"""
Infer per-observation release frequency from the spacing of end-of-period (eop) dates.
"""

from typing import Sequence, Tuple, Dict

import numpy as np
import pandas as pd

from macrosynergy.management.constants import ANNUALIZATION_FACTORS


def _reference_days(freqs: Sequence[str]) -> Dict[str, float]:
    # Calendar days per period, derived from periods-per-year. e.g. Q -> 365.25/4.
    return {f: 365.25 / ANNUALIZATION_FACTORS[f] for f in freqs}


def _sorted_annualization_factors(annualization_factors: Dict) -> Sequence[str]:
    _out = {f: ANNUALIZATION_FACTORS[f] for f in annualization_factors if len(f) == 1}
    # slowest first (fewest periods/year): A, Q, M, W, D
    return sorted(_out, key=_out.get)


[docs]def infer_release_frequency( eop: pd.Series, window: int = 3, freqs: Tuple[str, ...] = ("D", "W", "M", "Q", "A"), ) -> pd.Series: """ Classify the release frequency of each observation from its local ``eop`` cadence. The gap (in days) between consecutive *distinct* eop dates is smoothed with a rolling median (``window``, ``min_periods=1``) and snapped to the nearest supported frequency by log-distance to the reference period length (``365.25 / ANNUALIZATION_FACTORS``). Observations sharing an eop (revisions) inherit that period's frequency. Parameters ---------- eop : pd.Series per-observation end-of-period dates (datetime); the index is preserved. window : int rolling-median window over distinct-eop gaps. Default 3. freqs : Tuple[str, ...] candidate frequency labels. Default ("D", "W", "M", "Q", "A"). Returns ------- pd.Series per-observation frequency labels, aligned to the input index. Raises ------ ValueError if there are fewer than two distinct eop dates, so no gap can be computed to estimate a release frequency. """ eop = pd.to_datetime(eop) ref = _reference_days(freqs) log_ref = {f: np.log(d) for f, d in ref.items()} # Distinct, sorted eop periods and their smoothed gaps (in days). distinct = pd.Series(sorted(pd.unique(eop.dropna()))) if len(distinct) < 2: raise ValueError( "Not enough values in the timeseries to estimate a release frequency; " "at least two distinct eop dates are required." ) # Seed the leading NaN gap by back-filling from the first observed gap # (min_periods=1 covers the rest); avoids chained-assignment warnings. gaps = distinct.diff().dt.days.bfill() smoothed = gaps.rolling(window=window, min_periods=1).median() # Canonical slow->fast rank (A=0 ... D=4) over the full frequency set; ranks whatever # subset a caller passes, independent of the order they pass it in. _FREQ_RANK = { f: i for i, f in enumerate(_sorted_annualization_factors(ANNUALIZATION_FACTORS)) } def _snap(g): lg = np.log(g) # Tie-break by slowest freq (rank): deterministic, order-independent. return min(freqs, key=lambda f: (abs(lg - log_ref[f]), _FREQ_RANK[f])) period_freq = {d: _snap(g) for d, g in zip(distinct, smoothed)} return eop.map(period_freq)