Repository: sktime/sktime (python)

Issue:
[ENH] Bollinger bands
I noticed `sktime` has some tools for smoothing, but not the popular Bollinger bands used in trading.

While one can be of different opinion on the estimator, I think it is popular enough to have it in the "smoothing" section.

It should be a good first issue, given the simple nature of the estimator it should be straightforward to implement from scratch, using `pandas` or `numpy` directly.
Some thoughts:

* the `q` of the moving average should be a parameter
* the `remember_data` tag should be true, since stream use will require to remember previous data for computing the bands. So, transform should try to retrieve remembered data, and subset it to the range required to comput bands for indices in the input.

Pull request description:
[ENH] Bollinger Bands
<!--
Welcome to sktime, and thanks for contributing!
Please have a look at our contribution guide:
https://www.sktime.net/en/latest/get_involved/contributing.html
-->

#### Reference Issues/PRs
closes #6320.


#### What does this implement/fix? Explain your changes.
Implements the Bollinger Bands for a time series

#### Does your contribution introduce a new dependency? If yes, which one?

No

#### What should a reviewer concentrate their feedback on?

- Code structure
- Code Location
- Do we need an "inv_transform" for this? When would we use that?

#### Did you add any tests for the change?

<!-- This section is useful if you have added a test in addition to the existing ones. This will ensure that further changes to these files won't introduce the same kind of bug. It is considered good practice to add tests with newly added code to enforce the fact that the code actually works. This will reduce the chance of introducing logical bugs.
-->

#### Any other comments?
<!--
We value all user contributions, no matter how small or complex they are. If you have any questions, feel free to post
in the dev-chat channel on the sktime discord https://discord.com/invite/54ACzaFsn7. If we are slow to review (>3 working days), likewise feel free to ping us on discord. Thank you for your understanding during the review process.
-->

#### PR checklist
<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [x] I've added myself to the [list of contributors](https://github.com/sktime/sktime/blob/main/CONTRIBUTORS.md) with any new badges I've earned :-)
  How to: add yourself to the [all-contributors file](https://github.com/sktime/sktime/blob/main/.all-contributorsrc) in the `sktime` root directory (not the `CONTRIBUTORS.md`). Common badges: `code` - fixing a bug, or adding code logic. `doc` - writing or improving documentation or docstrings. `bug` - reporting or diagnosing a bug (get this plus `code` if you also fixed the bug in the PR).`maintenance` - CI, test framework, release.
  See here for [full badge reference](https://allcontributors.org/docs/en/emoji-key)
- [ ] Optionally, for added estimators: I've added myself and possibly to the `maintainers` tag - do this if you want to become the owner or maintainer of an estimator you added.
  See here for further details on the [algorithm maintainer role](https://www.sktime.net/en/latest/get_involved/governance.html#algorithm-maintainers).
- [x] The PR title starts with either [ENH], [MNT], [DOC], or [BUG]. [BUG] - bugfix, [MNT] - CI, test framework, [ENH] - adding or improving code, [DOC] - writing or improving documentation or docstrings.

##### For new estimators
- [x] I've added the estimator to the API reference - in `docs/source/api_reference/taskname.rst`, follow the pattern.
- [x] I've added one or more illustrative usage examples to the docstring, in a pydocstyle compliant `Examples` section.
- [x] If the estimator relies on a soft dependency, I've set the `python_dependencies` tag and ensured
  dependency isolation, see the [estimator dependencies guide](https://www.sktime.net/en/latest/developer_guide/dependencies.html#adding-a-soft-dependency).

<!--
Thanks for contributing!
-->


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 630f97689..c3bb48d3d 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -2649,7 +2649,8 @@
       "avatar_url": "https://avatars.githubusercontent.com/u/37468543?v=4",
       "profile": "https://github.com/meraldoantonio",
       "contributions": [
-        "doc"
+        "doc",
+        "bug"
       ]
     },
     {
@@ -2812,6 +2813,16 @@
       "contributions": [
         "doc"
       ]
+    },
+    {
+      "login": "ishanpai",
+      "name": "Ishan Paidhungat",
+      "avatar_url": "https://avatars.githubusercontent.com/u/73134788?v=4",
+      "profile": "https://github.com/ishanpai",
+      "contributions": [
+        "code",
+        "doc"
+      ]
     }
   ]
 }
diff --git a/README.md b/README.md
index bad9a6f9b..8aecfd334 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ sktime is a library for time series analysis in Python. It provides a unified in
 | **Community** | [![!discord](https://img.shields.io/static/v1?logo=discord&label=discord&message=chat&color=lightgreen)](https://discord.com/invite/54ACzaFsn7) [![!slack](https://img.shields.io/static/v1?logo=linkedin&label=LinkedIn&message=news&color=lightblue)](https://www.linkedin.com/company/scikit-time/)  |
 | **CI/CD** | [![github-actions](https://img.shields.io/github/actions/workflow/status/sktime/sktime/wheels.yml?logo=github)](https://github.com/sktime/sktime/actions/workflows/wheels.yml) [![!codecov](https://img.shields.io/codecov/c/github/sktime/sktime?label=codecov&logo=codecov)](https://codecov.io/gh/sktime/sktime) [![readthedocs](https://img.shields.io/readthedocs/sktime?logo=readthedocs)](https://www.sktime.net/en/latest/?badge=latest) [![platform](https://img.shields.io/conda/pn/conda-forge/sktime)](https://github.com/sktime/sktime) |
 | **Code** |  [![!pypi](https://img.shields.io/pypi/v/sktime?color=orange)](https://pypi.org/project/sktime/) [![!conda](https://img.shields.io/conda/vn/conda-forge/sktime)](https://anaconda.org/conda-forge/sktime) [![!python-versions](https://img.shields.io/pypi/pyversions/sktime)](https://www.python.org/) [![!black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)  |
-| **Downloads**| [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=week&units=international_system&left_color=grey&right_color=blue&left_text=weekly%20(pypi))](https://pepy.tech/project/sktime) [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=month&units=international_system&left_color=grey&right_color=blue&left_text=monthly%20(pypi))](https://pepy.tech/project/sktime) [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=total&units=international_system&left_color=grey&right_color=blue&left_text=cumulative%20(pypi))](https://pepy.tech/project/sktime) |
+| **Downloads** | ![PyPI - Downloads](https://img.shields.io/pypi/dw/sktime) ![PyPI - Downloads](https://img.shields.io/pypi/dm/sktime) [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=total&units=international_system&left_color=grey&right_color=blue&left_text=cumulative%20(pypi))](https://pepy.tech/project/sktime) |
 | **Citation** | [![!zenodo](https://zenodo.org/badge/DOI/10.5281/zenodo.3749000.svg)](https://doi.org/10.5281/zenodo.3749000) |
 
 ## :books: Documentation
diff --git a/docs/source/api_reference/performance_metrics.rst b/docs/source/api_reference/performance_metrics.rst
index 1a7a67c83..333ab2680 100644
--- a/docs/source/api_reference/performance_metrics.rst
+++ b/docs/source/api_reference/performance_metrics.rst
@@ -98,6 +98,7 @@ Distribution forecasts
     :toctree: auto_generated/
     :template: class_with_call.rst
 
+    AUCalibration
     CRPS
     LogLoss
     SquaredDistrLoss
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index 1bedaa6f2..18e70affd 100644
--- a/docs/source/api_reference/transformations.rst
+++ b/docs/source/api_reference/transformations.rst
@@ -392,6 +392,14 @@ Filtering and denoising
 
     ThetaLinesTransformer
 
+.. currentmodule:: sktime.transformations.series.bollinger
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    Bollinger
+
 Differencing, slope, kinematics
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/pyproject.toml b/pyproject.toml
index b3eec71be..cbb0ffd6f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -165,7 +165,7 @@ all_extras_pandas2 = [
 # users can install via "pip install sktime[forecasting,transformations]" etc
 #
 alignment = [
-  "dtw-python>=1.3,<1.5",
+  "dtw-python>=1.3,<1.6",
   'numba<0.60,>=0.53',
 ]
 annotation = [
@@ -244,7 +244,7 @@ docs = [
   "pydata-sphinx-theme",
   "Sphinx!=7.2.0,<8.0.0",
   "sphinx-copybutton",
-  "sphinx-design<0.6.0",
+  "sphinx-design<0.7.0",
   "sphinx-gallery<0.17.0",
   "sphinx-issues<5.0.0",
   "tabulate",
diff --git a/sktime/forecasting/var.py b/sktime/forecasting/var.py
index d4396751e..9bd0f9a77 100644
--- a/sktime/forecasting/var.py
+++ b/sktime/forecasting/var.py
@@ -245,7 +245,9 @@ class VAR(_StatsModelsAdapter):
 
         for cov in coverage:
             alpha = 1 - cov
-
+            # A hacky way to coerce error-inducing alpha==1 into its approximant
+            if alpha >= 0.99999:
+                alpha = 0.99999
             fcast_interval = model.forecast_interval(
                 self._y.values[-n_lags:], steps=steps, alpha=alpha
             )
diff --git a/sktime/performance_metrics/forecasting/probabilistic/__init__.py b/sktime/performance_metrics/forecasting/probabilistic/__init__.py
index fbab9f8df..68a072958 100644
--- a/sktime/performance_metrics/forecasting/probabilistic/__init__.py
+++ b/sktime/performance_metrics/forecasting/probabilistic/__init__.py
@@ -20,10 +20,12 @@ __all__ = [
     "IntervalWidth",
     "LogLoss",
     "SquaredDistrLoss",
+    "AUCalibration",
 ]
 
 from sktime.performance_metrics.forecasting.probabilistic._classes import (
     CRPS,
+    AUCalibration,
     ConstraintViolation,
     EmpiricalCoverage,
     IntervalWidth,
diff --git a/sktime/performance_metrics/forecasting/probabilistic/_classes.py b/sktime/performance_metrics/forecasting/probabilistic/_classes.py
index cfee5b76f..95342e610 100644
--- a/sktime/performance_metrics/forecasting/probabilistic/_classes.py
+++ b/sktime/performance_metrics/forecasting/probabilistic/_classes.py
@@ -946,7 +946,7 @@ class CRPS(_BaseDistrForecastingMetric):
     multivariate : bool, optional, default=False
         if True, behaves as multivariate CRPS (sum of scores)
         CRPS is computed for entire row, results one score per row
-        if False, is univariate log-loss, per variable
+        if False, returns univariate CRPS, per variable
         CRPS is computed per variable marginal, results in many scores per row
     """  # noqa: E501
 
@@ -964,3 +964,73 @@ class CRPS(_BaseDistrForecastingMetric):
         params1 = {}
         params2 = {"multivariate": True}
         return [params1, params2]
+
+
+class AUCalibration(_BaseDistrForecastingMetric):
+    r"""Area under the calibration curve for distributional predictions.
+
+    Computes the unsigned area between the calibration curve and the diagonal.
+
+    The calibration curve is the cumulative curve of the sample of
+    predictive cumulative distibution functions evaluated at the true values.
+
+    Mathematically, let :math:`d_1, \dots, d_N` be the predictive distributions,
+    let :math:`y_1, \dots, y_N` be the true values, and let :math:`F_i` be the
+    cumulative distribution function of :math:`d_i`.
+
+    Define the calibration sample as :math:`c_i := F_i(y_i)`, for
+    :math:`i = 1, \dots, N`. For perfect predictions, the sample of :math:`c_i` will be
+    uniformly distributed on [0, 1], and i.i.d. from that uniform distribution.
+
+    Let :math:`c_{(i)}` be the :math:`i`-th order statistic of the sample of
+    :math:`c_i`, i.e., the :math:`i`-th smallest value in the sample.
+
+    The (unsigned) area under the calibration curve - or, more precisely,
+    between the diagonal and the calibration curve - is defined as
+
+    .. math:: \frac{1}{N} \sum_{i=1}^N \left| c_{(i)} - \frac{i}{N} \right|.
+
+    * ``evaluate`` returns the unsigned area between the calibration curve
+      and the diagonal, i.e., the above quantity.
+    * ``evaluate_by_index`` returns, for the :math:`i`-th test sample, the value
+      :math:`\left| c_i - \frac{r_i}{N} \right|`,
+      where :math:`r_i` is the rank of :math:`c_i`
+      in the sample of :math:`c_i`. In case of ties, tied ranks are averaged.
+    * ``multivariate`` controls averaging over variables.
+
+    Parameters
+    ----------
+    multioutput : {'raw_values', 'uniform_average'} or array-like of shape \
+            (n_outputs,), default='uniform_average'
+        Defines whether and how to aggregate metric for across variables.
+        If 'uniform_average' (default), errors are mean-averaged across variables.
+        If array-like, errors are weighted averaged across variables, values as weights.
+        If 'raw_values', does not average errors across variables, columns are retained.
+    multivariate : bool, optional, default=False
+        if True, behaves as multivariate metric (sum of scores)
+        The metric is computed for entire row, results one score per row
+        if False, returns univariate metric, per variable
+        The metric is computed per variable marginal, results in many scores per row
+    """  # noqa: E501
+
+    def __init__(self, multioutput="uniform_average", multivariate=False):
+        self.multivariate = multivariate
+        super().__init__(multioutput=multioutput)
+
+    def _evaluate_by_index(self, y_true, y_pred, multioutput, **kwargs):
+        cdfs = y_pred.cdf(y_true)
+        # using the average in case of ranks is fine
+        # because the absolute sums in the metric average out
+        cdfs_ranked = cdfs.rank(axis=0, method="average", pct=True)
+        n = cdfs.shape[0]
+        diagonal = np.arange(1, n + 1).reshape(-1, 1) / n
+
+        res = (cdfs_ranked - diagonal).abs()
+        return res
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Retrieve test parameters."""
+        params1 = {}
+        params2 = {"multivariate": True}
+        return [params1, params2]
diff --git a/sktime/registry/_lookup.py b/sktime/registry/_lookup.py
index 1adbcf7b6..97f0aa2a3 100644
--- a/sktime/registry/_lookup.py
+++ b/sktime/registry/_lookup.py
@@ -136,16 +136,16 @@ def all_estimators(
     --------
     >>> from sktime.registry import all_estimators
     >>> # return a complete list of estimators as pd.Dataframe
-    >>> all_estimators(as_dataframe=True)
+    >>> all_estimators(as_dataframe=True)  # doctest: +SKIP
     >>> # return all forecasters by filtering for estimator type
-    >>> all_estimators("forecaster")
+    >>> all_estimators("forecaster")  # doctest: +SKIP
     >>> # return all forecasters which handle missing data in the input by tag filtering
-    >>> all_estimators("forecaster", filter_tags={"handles-missing-data": True})
+    >>> all_estimators("forecaster", filter_tags={"handles-missing-data": True})  # doctest: +SKIP
 
     References
     ----------
     Modified version from scikit-learn's ``all_estimators()``.
-    """
+    """  # noqa: E501
     MODULES_TO_IGNORE = (
         "tests",
         "setup",
diff --git a/sktime/transformations/series/bollinger.py b/sktime/transformations/series/bollinger.py
new file mode 100644
index 000000000..9c91d4521
--- /dev/null
+++ b/sktime/transformations/series/bollinger.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3 -u
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Class to apply bollinger bounds to a time series."""
+__author__ = ["ishanpai"]
+__all__ = ["Bollinger"]
+
+import pandas as pd
+
+from sktime.datatypes._utilities import get_cutoff, update_data
+from sktime.transformations.base import BaseTransformer
+
+
+class Bollinger(BaseTransformer):
+    """Apply Bollinger bounds to a timeseries.
+
+    The transformation works for univariate and multivariate timeseries.
+
+    Parameters
+    ----------
+    window : int
+        The window over which to compute the moving average and the standard deviation.
+
+    k: float, default = 1
+        Multiplier to determine how many stds the upper and lower bounds are from the
+        moving average.
+
+    memory : str, optional, default = "all"
+        how much of previously seen X to remember, for exact reconstruction of inverse
+        "all" : estimator remembers all X, inverse is correct for all indices seen
+        "latest" : estimator only remembers latest X necessary for future reconstruction
+            inverses at any time stamps after fit are correct, but not past time stamps
+        "none" : estimator does not remember any X, inverse is direct cumsum
+
+    Examples
+    --------
+    >>> from sktime.transformations.series.bollinger import Bollinger
+    >>> from sktime.datasets import load_airline
+    >>> y = load_airline()
+    >>> transformer = Bollinger(window=12, k=1)
+    >>> y_transform = transformer.fit_transform(y)
+    """
+
+    _tags = {
+        # packaging info
+        # --------------
+        "authors": ["ishanpai"],
+        # estimator type
+        # --------------
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        "scitype:transform-output": "Series",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": False,  # is this an instance-wise transform?
+        "X_inner_mtype": ["pd.DataFrame", "pd.Series"],
+        # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "fit_is_empty": False,
+        "transform-returns-same-time-index": True,
+        "univariate-only": False,
+        "capability:inverse_transform": False,
+        "remember_data": True,
+    }
+
+    def __init__(self, window, k=1, memory="all"):
+        if window <= 1:
+            raise ValueError(f"window must be greater than 1, passed {window}")
+        if k <= 0:
+            raise ValueError(f"k must be positive, passed {k}")
+        self.window = window
+        self.k = k
+        self.memory = memory
+
+        self._X = None
+        super().__init__()
+
+    def _fit(self, X, y=None):
+        """Fit transformer to X and y.
+
+        private _fit containing the core logic, called from fit
+
+        Parameters
+        ----------
+        X : pd.Series or pd.DataFrame
+            Data to fit transform to
+        y : ignored argument for interface compatibility
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        self: a fitted instance of the estimator
+        """
+        memory = self.memory
+
+        # remember X or part of X
+        if memory == "all":
+            self._X = X
+        elif memory == "latest":
+            n_memory = min(len(X), self.window)
+            self._X = X.iloc[-n_memory:]
+
+        self._freq = get_cutoff(X, return_index=True)
+        return self
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing the core logic, called from transform
+
+        Parameters
+        ----------
+        X : pd.Series or pd.DataFrame
+            Data to be transformed
+        y : ignored argument for interface compatibility
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        Xt : pd.Series or pd.DataFrame, same type as X
+            transformed version of X
+        """
+        X_orig_index = X.index
+
+        X = update_data(X=self._X, X_new=X)
+
+        Xt = _bollinger_transform(X, self.window, self.k)
+
+        Xt = Xt.loc[X_orig_index]
+
+        return Xt
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return ``"default"`` set.
+            There are currently no reserved values for transformers.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            ``MyClass(**params)`` or ``MyClass(**params[i])`` creates a valid test
+            instance.
+            ``create_test_instance`` uses the first (or only) dictionary in ``params``
+        """
+        return [{"window": 12, "k": 1}, {"window": 2, "k": 1.2}]
+
+
+def _bollinger_transform(df: pd.Series, window: int, k: float) -> pd.DataFrame:
+    df_ma = df.rolling(window=window).mean()
+    df_std = df.rolling(window=window).std()
+    df_upper = df_ma + k * df_std
+    df_lower = df_ma - k * df_std
+
+    df_transformed = pd.concat(
+        [df_ma, df_upper, df_lower], keys=("moving_average", "upper", "lower"), axis=1
+    )
+
+    return df_transformed


Test patch:
diff --git a/sktime/transformations/series/tests/test_bollinger.py b/sktime/transformations/series/tests/test_bollinger.py
new file mode 100644
index 000000000..f341e3623
--- /dev/null
+++ b/sktime/transformations/series/tests/test_bollinger.py
@@ -0,0 +1,32 @@
+"""Tests for Bollinger."""
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+
+__author__ = ["ishanpai"]
+__all__ = []
+
+import numpy as np
+import pytest
+
+from sktime.datasets import load_airline
+from sktime.transformations.series.bollinger import Bollinger
+
+
+@pytest.mark.parametrize("window", [2, 12])
+@pytest.mark.parametrize("k", [1, 5])
+def test_bollinger_against_raw_implementation(window, k):
+    y = load_airline()
+    t = Bollinger(window=window, k=k)
+    y_t = t.fit_transform(y)
+
+    ma = y.rolling(window=window).mean()
+    std = y.rolling(window=window).std()
+
+    np.testing.assert_array_equal(y_t["moving_average"].values, ma.values)
+    np.testing.assert_array_equal(y_t["upper"].values, (ma + k * std).values)
+    np.testing.assert_array_equal(y_t["lower"].values, (ma - k * std).values)
+
+
+@pytest.mark.parametrize(("window", "k"), [(1, 1), (12, 0), (1, 0)])
+def test_bollinger_input_error(window, k):
+    with pytest.raises(ValueError):
+        Bollinger(window=window, k=k)