Source code for finance_calcs._periods

"""Period and calendar-bucket helpers for expression metrics."""

from __future__ import annotations

import math
from typing import TypeAlias

import polars as pl
from finance_enums import Frequency, to_frequency

try:
    from finance_dates import period_grid as dates_period_grid
except ImportError:  # pragma: no cover - compatibility with older finance-dates

    def dates_period_grid(date: pl.Expr, period: Frequency | str | pl.Expr) -> pl.Expr:
        if isinstance(period, pl.Expr):
            return period
        if isinstance(period, Frequency):
            return date.dt.truncate(period.polars_truncate)
        value = period.strip()
        if not value:
            raise ValueError("period must not be empty")
        try:
            return date.dt.truncate(to_frequency(value).polars_truncate)
        except ValueError:
            return date.dt.truncate(value)


PeriodLike: TypeAlias = Frequency | str | pl.Expr
FrequencyLike: TypeAlias = Frequency | str | float


def _observations_per_year(frequency: FrequencyLike) -> float:
    """Resolve an annualization frequency to observations per year."""
    if isinstance(frequency, Frequency | str):
        return float(to_frequency(frequency).periods_per_year)
    if isinstance(frequency, bool):
        raise TypeError("frequency must be a Frequency, alias, or positive number")
    value = float(frequency)
    if not math.isfinite(value) or value <= 0.0:
        raise ValueError("numeric frequency must be finite and positive")
    return value


def _annual_rate_to_observation_rate(rate: float | pl.Expr, observations_per_year: float) -> float | pl.Expr:
    """Convert an annual scalar rate to one observation's rate.

    Expression rates are assumed to already match the observation frequency.
    """
    if isinstance(rate, pl.Expr):
        return rate
    if not math.isfinite(rate) or rate <= -1.0:
        raise ValueError("annual scalar rates must be finite and greater than -1")
    if rate == 0.0:
        return 0.0
    return (1.0 + rate) ** (1.0 / observations_per_year) - 1.0


[docs] def period_bucket(date: pl.Expr, period: PeriodLike) -> pl.Expr: """Return a period bucket expression for ``date``. ``period`` accepts a :class:`finance_enums.Frequency`, any alias understood by ``finance_enums.to_frequency()``, any Polars duration string accepted by ``dt.truncate()``, or a precomputed bucket expression. """ return dates_period_grid(date, period)
def _check_window_period(window: int | None, period: PeriodLike | None) -> None: if window is not None and period is not None: raise ValueError("window and period are mutually exclusive") def _bucket_or_none(date: pl.Expr | None, period: PeriodLike | None) -> pl.Expr | None: if period is None: return None if isinstance(period, pl.Expr): return period if date is None: raise ValueError("date is required when period is a Frequency or duration string") return period_bucket(date, period)