{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Interactive Plot"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Description\n",
    "\n",
    "Allows interactive plotting of multidimensional data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "metadata": {
    "ExecuteTime": {
     "end_time": "2022-11-09T01:31:42.387141Z",
     "start_time": "2022-11-09T01:31:40.779452Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "running typecheck\n",
      "Success: no issues found in 1 source file\n",
      "\n",
      "running flake8\n",
      "flake8 success\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "9dcbd45f913d40ec9aef37c44d740c72",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Box(children=(Dropdown(description='Year', options=('All', 2018, 2019, 2020, 2021), style=DescriptionStyle(des…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "f106ac8dcf194020a8b89f8dd2d5dd66",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Box(children=(Dropdown(description='Year', options=('All', 2018, 2019, 2020, 2021), style=DescriptionStyle(des…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      ".\n",
      "----------------------------------------------------------------------\n",
      "Ran 1 test in 0.233s\n",
      "\n",
      "OK\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "done\n"
     ]
    }
   ],
   "source": [
    "%%checkall\n",
    "from __future__ import annotations\n",
    "import os\n",
    "import sys\n",
    "import math\n",
    "import colorsys\n",
    "from dataclasses import dataclass\n",
    "import unittest\n",
    "import doctest\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "from IPython.display import display, clear_output\n",
    "from ipywidgets import widgets\n",
    "import plotly\n",
    "import plotly.callbacks\n",
    "import plotly.graph_objects as go\n",
    "from plotly.subplots import make_subplots\n",
    "from typing import Callable, Any\n",
    "from collections.abc import Sequence\n",
    "import traitlets\n",
    "from pyqstrat.pq_utils import bootstrap_ci, get_child_logger\n",
    "\n",
    "ROOT_DIR = os.path.join(sys.path[0])\n",
    "sys.path.insert(1, ROOT_DIR)\n",
    "\n",
    "_logger = get_child_logger(__name__)\n",
    "\n",
    "DEFAULT_PLOTLY_COLORS = ['rgb(31, 119, 180)', 'rgb(255, 127, 14)',\n",
    "                         'rgb(44, 160, 44)', 'rgb(214, 39, 40)',\n",
    "                         'rgb(148, 103, 189)', 'rgb(140, 86, 75)',\n",
    "                         'rgb(227, 119, 194)', 'rgb(127, 127, 127)',\n",
    "                         'rgb(188, 189, 34)', 'rgb(23, 190, 207)']\n",
    "\n",
    "LineDataType = tuple[str, pd.DataFrame, dict[Any, pd.DataFrame]]\n",
    "    \n",
    "DimensionFilterType = Callable[[\n",
    "    pd.DataFrame,\n",
    "    str,\n",
    "    list[tuple[str, Any]]],\n",
    "    np.ndarray]\n",
    "\n",
    "DataFilterType = Callable[[\n",
    "    pd.DataFrame,\n",
    "    list[tuple[str, Any]]],\n",
    "    pd.DataFrame]\n",
    "\n",
    "StatFuncType = Callable[[pd.DataFrame, str, str, str], list[LineDataType]]\n",
    "\n",
    "DetailDisplayType = Callable[[\n",
    "    widgets.Widget, \n",
    "    pd.DataFrame,\n",
    "    bool],\n",
    "    None]\n",
    "\n",
    "\n",
    "PlotFuncType = Callable[[str, str, list[LineDataType]], list[widgets.Widget]]\n",
    "\n",
    "DataFrameTransformFuncType = Callable[[pd.DataFrame], pd.DataFrame]\n",
    "\n",
    "SeriesTransformFuncType = Callable[[pd.Series], pd.Series]\n",
    "\n",
    "DisplayFormFuncType = Callable[[Sequence[widgets.Widget], bool], None]\n",
    "\n",
    "UpdateFormFuncType = Callable[[int], None]\n",
    "\n",
    "CreateSelectionWidgetsFunctype = Callable[[dict[str, str], dict[str, str], UpdateFormFuncType], dict[str, Any]]\n",
    "\n",
    "\n",
    "def percentile_buckets(a: np.ndarray, n=10) -> np.ndarray:\n",
    "    '''\n",
    "    >>> np.random.seed(0)\n",
    "    >>> a = np.random.uniform(size=10000)\n",
    "    >>> assert np.allclose(np.unique(percentile_buckets(a)), np.arange(0.05, 1, 0.1), atol=0.01)\n",
    "    '''\n",
    "    if not len(a): return np.empty(0)\n",
    "    pctiles = np.arange(0, 100, int(round(100 / n))) \n",
    "    buckets = np.nanpercentile(a, pctiles)\n",
    "    conditions: list[Any] = []\n",
    "    for i, bucket in enumerate(buckets[:-1]):\n",
    "        if buckets[i] == buckets[i + 1]:\n",
    "            conditions.append(a == buckets[i])\n",
    "        else:\n",
    "            conditions.append((a >= buckets[i]) & (a < buckets[i + 1]))\n",
    "    conditions.append((a >= buckets[-1]))\n",
    "    b = [np.mean(a[cond]) for cond in conditions]\n",
    "    ret = np.select(conditions, b)\n",
    "    return ret\n",
    "\n",
    "\n",
    "def display_form(form_widgets: Sequence[widgets.Widget], debug=False) -> None:\n",
    "    if not debug: clear_output()\n",
    "    box_layout = widgets.Layout(\n",
    "        display='flex',\n",
    "        flex_flow='column',\n",
    "        align_items='stretch',\n",
    "        border='solid',\n",
    "        width='100%')\n",
    "    box = widgets.Box(children=list(form_widgets), layout=box_layout)\n",
    "    display(box)\n",
    "    \n",
    "    \n",
    "class SimpleTransform:\n",
    "    '''\n",
    "    Initial transformation of data.  For example, you might add columns that are quantiles of other columns\n",
    "    '''\n",
    "    def __init__(self, transforms: list[tuple[str, str, SeriesTransformFuncType]] | None = None) -> None:\n",
    "        self.transforms = [] if transforms is None else transforms\n",
    "        \n",
    "    def __call__(self, data: pd.DataFrame) -> pd.DataFrame:\n",
    "        for (colname, new_colname, func) in self.transforms:\n",
    "            data[new_colname] = func(data[colname])\n",
    "        return data\n",
    "    \n",
    "\n",
    "def simple_dimension_filter(data: pd.DataFrame, dim_name: str, selected_values: list[tuple[str, Any]]) -> np.ndarray:\n",
    "    '''\n",
    "    Produces a list to put into a dropdown for selecting a dimension value\n",
    "    '''\n",
    "    mask = np.full(len(data), True)\n",
    "    for name, value in selected_values:\n",
    "        if value == 'All': continue\n",
    "        mask &= (data[name] == value)\n",
    "    values = np.unique(data[mask][dim_name].values)  # will sort values before returning them\n",
    "    return ['All'] + values.tolist()\n",
    "    \n",
    "    \n",
    "def simple_data_filter(data: pd.DataFrame, selected_values: list[tuple[str, Any]]) -> pd.DataFrame:\n",
    "    '''\n",
    "    Filters a dataframe based on the selected values\n",
    "    '''\n",
    "    mask = np.full(len(data), True)\n",
    "    for name, value in selected_values:\n",
    "        if value == 'All': continue\n",
    "        mask &= (data[name] == value)\n",
    "    return data[mask]\n",
    "    \n",
    "    \n",
    "class MeanWithCI:\n",
    "    '''\n",
    "    Computes mean (or median) and optionally confidence intervals for plotting\n",
    "    '''\n",
    "    def __init__(self, mean_func: Callable[[np.ndarray], np.ndarray] = np.nanmean, ci_level: int = 0) -> None:  # type: ignore\n",
    "        '''\n",
    "        Args:\n",
    "            mean: The function to compute ci for\n",
    "            ci_level: Set to 0 for no confidence intervals, or the level you want.\n",
    "                For example, set to 95 to compute a 95% confidence interval. Default 0\n",
    "        '''\n",
    "        self.mean_func = mean_func\n",
    "        self.ci_level = ci_level\n",
    "        \n",
    "    def __call__(self, filtered_data: pd.DataFrame, xcol: str, ycol: str, zcol: str) -> list[LineDataType]:\n",
    "        '''\n",
    "        For each unique value of x and z, compute mean (and optionally ci) of y.\n",
    "        Return:\n",
    "            x, y data for plotting lines of the mean of y versus x for each z and the data used to compute the mean\n",
    "        '''\n",
    "        zvals = np.unique(filtered_data[zcol])\n",
    "        cols = [col for col in filtered_data.columns if col not in [xcol, ycol, zcol]]\n",
    "        df = filtered_data[[xcol, ycol, zcol] + cols]\n",
    "        ret = []\n",
    "        columns = [xcol, ycol] if not self.ci_level else [xcol, ycol, f'ci_d_{self.ci_level}', f'ci_u_{self.ci_level}']\n",
    "        for zvalue in zvals:\n",
    "            df = filtered_data[filtered_data[zcol] == zvalue]\n",
    "            plt_data: list[Any] = []\n",
    "            for x, yseries in df.groupby(xcol)[ycol]:\n",
    "                y = yseries.values\n",
    "                if not len(y): raise Exception(y)\n",
    "                mean = self.mean_func(y)\n",
    "                if self.ci_level:\n",
    "                    ci_up, ci_down = bootstrap_ci(y, ci_level=self.ci_level / 100)\n",
    "                    plt_data.append((x, mean, ci_down, ci_up))\n",
    "                else:\n",
    "                    plt_data.append((x, mean))\n",
    "            line = pd.DataFrame.from_records(plt_data, columns=columns)\n",
    "            ret.append((zvalue, line, df))\n",
    "        return ret\n",
    "    \n",
    "    \n",
    "class SimpleDetailTable:\n",
    "    '''\n",
    "    Displays a pandas DataFrame under a plot that contains the data used to compute a statistic of y for each x, y pair\n",
    "    '''\n",
    "    \n",
    "    def __init__(self, \n",
    "                 colnames: list[str] | None = None, \n",
    "                 float_format: str = '{:.4g}', \n",
    "                 min_rows: int = 100, \n",
    "                 copy_to_clipboard: bool = True) -> None:\n",
    "        '''\n",
    "        Args:\n",
    "            colnames: list of column names to display. If None we display all columns. Default None\n",
    "            float_format: Format for each floating point column. Default {:.4g}\n",
    "            min_rows: Do not truncate the display of the table before this many rows. Default 100\n",
    "            copy_to_clipboard: If set, we copy the dataframe to the clipboard. On linux, you must install xclip for this to work\n",
    "       '''\n",
    "        self.colnames = colnames\n",
    "        self.float_format = float_format\n",
    "        self.min_rows = min_rows\n",
    "        self.copy_to_clipboard = True\n",
    "        \n",
    "    def __call__(self, detail_widget: widgets.Widget, data: pd.DataFrame, debug=False) -> None:\n",
    "        '''\n",
    "        Args:\n",
    "            detail_widget: The widget to display the data in\n",
    "            data: The dataframe to display\n",
    "        '''\n",
    "        if self.float_format:\n",
    "            orig_float_format = pd.options.display.float_format\n",
    "            pd.options.display.float_format = (self.float_format).format\n",
    "        \n",
    "        if self.min_rows:\n",
    "            orig_min_rows = pd.options.display.min_rows\n",
    "            pd.options.display.min_rows = self.min_rows\n",
    "            \n",
    "        with detail_widget:\n",
    "            if not debug: clear_output()\n",
    "            if self.colnames: data = data[self.colnames]\n",
    "            data = data.reset_index(drop=True)\n",
    "            display(data)\n",
    "            if self.copy_to_clipboard: data.to_clipboard(index=False)\n",
    "                \n",
    "        if self.float_format: pd.options.display.float_format = orig_float_format\n",
    "        if self.min_rows: pd.options.display.min_rows = orig_min_rows\n",
    "            \n",
    "            \n",
    "def create_selection_dropdowns(dims: dict[str, str], labels: dict[str, str], update_form_func: UpdateFormFuncType) -> dict[str, Any]:\n",
    "    '''\n",
    "    Create a list of selection widgets\n",
    "    '''\n",
    "    selection_widgets: dict[str, widgets.Widget] = {}\n",
    "    for name in dims.keys():\n",
    "        label = labels[name] if name in labels else name\n",
    "        widget = widgets.Dropdown(description=label, style={'description_width': 'initial'})\n",
    "        selection_widgets[name] = widget\n",
    "        \n",
    "    for widget in selection_widgets.values():\n",
    "        widget.observe(lambda x: on_widgets_updated(x, update_form_func, selection_widgets), names='value')\n",
    "\n",
    "    return selection_widgets\n",
    "\n",
    "\n",
    "def on_widgets_updated(change: traitlets.utils.bunch.Bunch, update_form_func, selection_widgets: dict[str, widgets.Widget]) -> None:\n",
    "    '''\n",
    "    Callback called by plotly when widgets are updated by the user.\n",
    "    '''\n",
    "    owner = change['owner']\n",
    "    widgets = list(selection_widgets.values())\n",
    "    owner_idx = widgets.index(owner)\n",
    "    update_form_func(owner_idx)\n",
    "            \n",
    "            \n",
    "@dataclass\n",
    "class LineConfig:\n",
    "    color: str | None = None\n",
    "    thickness: float = math.nan\n",
    "    secondary_y: bool = False\n",
    "    marker_mode: str = 'lines+markers'\n",
    "    show_detail: bool = True\n",
    "        \n",
    "        \n",
    "def _plotly_color_to_rgb(plotly_color: str) -> tuple[int, int, int]:\n",
    "    '''\n",
    "    Convert plotly color which is a string into r, g, b values\n",
    "    >>> assert _plotly_color_to_rgb('rgb(31, 119, 180)') == (31, 119, 180)\n",
    "    '''\n",
    "    plotly_color = plotly_color.replace('rgb(', '').replace(')', '')\n",
    "    s = plotly_color.split(',')\n",
    "    r, g, b = int(s[0]), int(s[1]), int(s[2])\n",
    "    return r, g, b\n",
    "\n",
    "        \n",
    "def _lighten_color(r: int, g: int, b: int) -> tuple[int, int, int]:\n",
    "    '''\n",
    "    Lighten color so we can show confidence intervals in a lighter shade than the line itself\n",
    "    We convert to hls and increase lightness and decrease saturation\n",
    "    >>> assert _lighten_color(31, 119, 180) == (102, 168, 214)\n",
    "    '''\n",
    "    hls = colorsys.rgb_to_hls(r, g, b)\n",
    "    light_hls = (hls[0], hls[1] * 1.5, hls[2] * 0.5)\n",
    "    rgb = colorsys.hls_to_rgb(*light_hls)\n",
    "    rgb = (int(round(rgb[0])), int(round(rgb[1])), int(round(rgb[2])))\n",
    "    return rgb\n",
    "\n",
    "\n",
    "def foo(name, old, new):\n",
    "    import datetime\n",
    "    print(f'hello: {datetime.datetime.now()} {name} {old} {new}')\n",
    "    \n",
    "        \n",
    "class LineGraphWithDetailDisplay:\n",
    "    '''\n",
    "    Draws line graphs and also includes a detail pane.\n",
    "    When you click on a point on the line graph, the detail pane shows the data used to compute that point.\n",
    "    '''\n",
    "    \n",
    "    def __init__(self, \n",
    "                 display_detail_func: DetailDisplayType = SimpleDetailTable(), \n",
    "                 line_configs: dict[str, LineConfig] = {}, \n",
    "                 title: str | None = None, \n",
    "                 hovertemplate: str | None = None, \n",
    "                 debug=False) -> None:\n",
    "        '''\n",
    "        Args:\n",
    "            display_detail_func: A function that displays the data on the detail pane. Default SimpleDetailTable\n",
    "            line_configs: Configuration of each line. The key in this dict is the zvalue for that line.  Default {}\n",
    "            title: Title of the graph. Default None\n",
    "            hovertemplate: What to display when we hover over a point on the graph.  See plotly hovertemplate\n",
    "        '''\n",
    "        self.display_detail_func = display_detail_func\n",
    "        self.line_configs = line_configs\n",
    "        self.title = title\n",
    "        self.hovertemplate = hovertemplate\n",
    "        self.debug = debug\n",
    "        self.default_line_config = LineConfig()\n",
    "        self.detail_data: dict[Any, pd.DataFrame] = {}\n",
    "        self.xcol = ''\n",
    "        self.zvalues: dict[int, Any] = {}  # trace index by zvalue\n",
    "            \n",
    "    def __call__(self, xaxis_title: str, yaxis_title: str, line_data: list[LineDataType]) -> list[widgets.Widget]:\n",
    "        '''\n",
    "        Draw the plot and also set it up so if you click on a point, we display the data used to compute that point.\n",
    "        Args:\n",
    "            line_data: The zvalue, plot data, and detail data for each line to draw. The plot data must have \n",
    "            x as the first column and y as the second column\n",
    "         Return:\n",
    "            A list of widgets to draw.  In this case, a figure widget and a output widget which contains the detail display\n",
    "        '''\n",
    "        if not len(line_data): return []\n",
    "        self.detail_data.clear()\n",
    "        secondary_y = any([lc.secondary_y for lc in self.line_configs.values()])\n",
    "        \n",
    "        fig_widget = go.FigureWidget(make_subplots(specs=[[{\"secondary_y\": secondary_y}]]))\n",
    "        # fig_widget.on_trait_change(foo, '_js2py_restyle')\n",
    "        # fig_widget.on_trait_change()\n",
    "        detail_widget = widgets.Output()\n",
    "        \n",
    "        trace_num = 0\n",
    "\n",
    "        for line_num, (zvalue, line_df, _detail_data) in enumerate(line_data):\n",
    "            x = line_df.iloc[:, 0].values\n",
    "            self.xcol = line_df.columns[0]\n",
    "            y = line_df.iloc[:, 1].values\n",
    "            self.detail_data[zvalue] = _detail_data\n",
    "            line_config = self.line_configs[zvalue] if zvalue in self.line_configs else self.default_line_config\n",
    "            marker_mode = line_config.marker_mode\n",
    "            color = line_config.color if line_config.color else DEFAULT_PLOTLY_COLORS[line_num]\n",
    "            \n",
    "            hovertemplate = self.hovertemplate\n",
    "            customdata = None\n",
    "            \n",
    "            if hovertemplate is None:\n",
    "                unique, counts = np.unique(_detail_data[self.xcol].values, return_counts=True)\n",
    "                customdata = counts[np.searchsorted(unique, x)]\n",
    "                hovertemplate = 'N: %{customdata}'  # number of entries used to compute each x\n",
    "                hovertemplate += f' Series: {zvalue} {xaxis_title}: ' + '%{x:.4g} ' + f'{yaxis_title}: ' + '%{y:.4g}'\n",
    "                \n",
    "            trace = go.Scatter(\n",
    "                x=x,\n",
    "                y=y,\n",
    "                customdata=customdata,\n",
    "                mode=marker_mode,\n",
    "                name=str(zvalue),\n",
    "                line=dict(color=color),\n",
    "                hovertemplate=hovertemplate              \n",
    "            )\n",
    "            \n",
    "            self.zvalues[trace_num] = zvalue\n",
    "            \n",
    "            fig_widget.add_trace(trace, secondary_y=line_config.secondary_y)\n",
    "            if line_config.show_detail:\n",
    "                fig_widget.data[trace_num].on_click(self._on_graph_click, append=True)\n",
    "            trace_num += 1\n",
    "\n",
    "            if len(line_df.columns) > 2:  # x, y, ci up and ci down \n",
    "                fill_color = _plotly_color_to_rgb(color)\n",
    "                fill_color = _lighten_color(*fill_color)\n",
    "                # we set transparency to 0.5 so we can see lines under the ci fill\n",
    "                fill_color_str = f'rgba({fill_color[0]},{fill_color[1]},{fill_color[2]},0.5)'\n",
    "                ci_down = line_df.iloc[:, 2].values\n",
    "                ci_up = line_df.iloc[:, 3].values\n",
    "                ci_trace = go.Scatter(\n",
    "                    x=np.concatenate([x, x[::-1]]),  # x, then x reversed\n",
    "                    y=np.concatenate([ci_up, ci_down[::-1]]),  # upper, then lower reversed\n",
    "                    fill='toself',\n",
    "                    fillcolor=fill_color_str,\n",
    "                    line=dict(color='rgba(255,255,255,0)'),\n",
    "                    hoverinfo=\"skip\",\n",
    "                    showlegend=False)\n",
    "            \n",
    "                fig_widget.add_trace(ci_trace, secondary_y=line_config.secondary_y)\n",
    "                trace_num += 1\n",
    "            \n",
    "        fig_widget.update_layout(title=self.title, xaxis_title=xaxis_title)\n",
    "        fig_widget.update_layout(yaxis_title=yaxis_title)\n",
    "        \n",
    "        if secondary_y:\n",
    "            fig_widget.update_yaxes(title_text=yaxis_title, secondary_y=True)\n",
    "            \n",
    "        self.fig_widget = fig_widget\n",
    "        self.detail_widget = detail_widget\n",
    "        self.line_data = line_data\n",
    "                        \n",
    "        return [self.fig_widget, self.detail_widget]\n",
    "    \n",
    "    def _on_graph_click(self, \n",
    "                        trace: go.Trace, \n",
    "                        points: plotly.callbacks.Points, \n",
    "                        selector: plotly.callbacks.InputDeviceState) -> None:\n",
    "        '''\n",
    "        Callback called by plotly when you click on a point on the graph.\n",
    "        When you click on a point, we display the dataframe with the data we used to compute that point.\n",
    "        '''\n",
    "        if not len(points.xs): return\n",
    "        trace_idx = points.trace_index\n",
    "        zvalue = self.zvalues[trace_idx]\n",
    "        _detail_data = self.detail_data[zvalue]\n",
    "        _detail_data = _detail_data[_detail_data[self.xcol].values == points.xs[0]]\n",
    "        self.display_detail_func(self.detail_widget, _detail_data, self.debug)\n",
    "\n",
    "\n",
    "class InteractivePlot:\n",
    "    '''\n",
    "    Creates a multidimensional interactive plot off a dataframe.\n",
    "    '''\n",
    "    def __init__(self,\n",
    "                 data: pd.DataFrame,\n",
    "                 labels: dict[str, str] | None = None,\n",
    "                 transform_func: DataFrameTransformFuncType = SimpleTransform(),\n",
    "                 create_selection_widgets_func: CreateSelectionWidgetsFunctype = create_selection_dropdowns,\n",
    "                 dim_filter_func: DimensionFilterType = simple_dimension_filter,\n",
    "                 data_filter_func: DataFilterType = simple_data_filter,\n",
    "                 stat_func: StatFuncType = MeanWithCI(),\n",
    "                 plot_func: PlotFuncType = LineGraphWithDetailDisplay(),\n",
    "                 display_form_func: DisplayFormFuncType = display_form,\n",
    "                 debug=False) -> None:\n",
    "        '''\n",
    "        Args:\n",
    "            data: The pandas dataframe to use for plotting\n",
    "            labels: A dict where column names from the dataframe are mapped to user friendly labels. For any column names\n",
    "                not found as keys in this dict, we use the column name as the label. Default None\n",
    "            dim_filter_func: A function that generates the values of a dimension based on other dimensions. For example, if \n",
    "                the user chooses \"Put Option\" in a put/call dropdown, the valid strikes could change in a Strike \n",
    "                dropdown that follows. Default simple_dimension_filter\n",
    "            data_filter_func: A function that filters the data to plot. For example, if the user chooses \"Put Option\" in a put/call dropdown,\n",
    "                we could filter the dataframe to only include put options. Default simple_data_filter\n",
    "            stat_func: Once we have filtered the data, we may need to plot some statistics, such as mean and confidence intervals.\n",
    "                In this function, we compute these statistics. Default MeanWithCI()\n",
    "            plot_func: A function that plots the data.  This could also display detail data used to compute the statistics associated\n",
    "                with each data point.\n",
    "            display_form_func: A function that displays the form given a list of plotly widgets (including the graph widget)\n",
    "            debug: Dont clear forms if this is true so we can see print output\n",
    "        '''\n",
    "        self.data = data\n",
    "        self.transform_func = transform_func\n",
    "        self.create_selection_widgets_func = create_selection_widgets_func\n",
    "        if labels is None: labels = {}\n",
    "        self.labels = labels\n",
    "        self.dim_filter_func = dim_filter_func\n",
    "        self.data_filter_func = data_filter_func\n",
    "        self.stat_func = stat_func\n",
    "        self.plot_func = plot_func\n",
    "        self.display_form_func = display_form_func\n",
    "        self.selection_widgets: dict[str, Any] = {}\n",
    "        self.debug = debug\n",
    "        \n",
    "    def create_pivot(self, xcol: str, ycol: str, zcol: str, dimensions: dict[str, Any]) -> None:\n",
    "        '''\n",
    "        Create the initial pivot\n",
    "        Args:\n",
    "            xcol: Column name to use as the x axis in the DataFrame\n",
    "            ycol: Column name to use as the y axis in the DataFrame\n",
    "            zcol: Column name to use for z-values. Each zvalue can be used for a different trace within this plot. For example, a column\n",
    "            called \"option_type\" could contain the values \"American\", \"European\", \"Bermudan\" and we could plot the data for each type\n",
    "            in a separate trace\n",
    "            dimensions: The column names used for filter dimensions. For example, we may want to filter by days to expiration and put/call\n",
    "            The key the column name and the value is the initial value for that column. For example, in a \n",
    "            dropdown for Put/Call we may want \"Put\" to be the initial value set in the dropdown.  Set to None if you \n",
    "            don't care what initial value is chosen.\n",
    "        '''\n",
    "        self.xlabel = xcol if xcol not in self.labels else self.labels[xcol]\n",
    "        self.ylabel = ycol if ycol not in self.labels else self.labels[ycol]\n",
    "        self.zcol = zcol\n",
    "        self.xcol = xcol\n",
    "        self.ycol = ycol\n",
    "        self.selection_widgets = self.create_selection_widgets_func(dimensions, self.labels, self.update)\n",
    "        self.update()\n",
    "        \n",
    "    def update(self, owner_idx: int = -1) -> None:\n",
    "        '''\n",
    "        Redraw the form using the values of all widgets above and including the one with index owner_idx.\n",
    "        If owner_idx is -1, we redraw everything.\n",
    "        '''\n",
    "        select_conditions = [(name, widget.value) for name, widget in self.selection_widgets.items()]\n",
    "        if owner_idx == -1:\n",
    "            dim_select_conditions = []\n",
    "        else:\n",
    "            dim_select_conditions = select_conditions[:owner_idx + 1]  # for selecting lower widget options, use value of widgets above \n",
    "        \n",
    "        for name in list(self.selection_widgets.keys())[owner_idx + 1:]:\n",
    "            widget = self.selection_widgets[name]\n",
    "            widget_options = self.dim_filter_func(self.data, name, dim_select_conditions)\n",
    "            _logger.debug(f'setting values: {widget_options} on widget: {name}')\n",
    "            widget.options = self.dim_filter_func(self.data, name, dim_select_conditions)\n",
    "            \n",
    "        if owner_idx == -1: return\n",
    "                         \n",
    "        filtered_data = self.data_filter_func(self.data, select_conditions)\n",
    "        transformed_data = self.transform_func(filtered_data)\n",
    "        lines = self.stat_func(transformed_data, self.xcol, self.ycol, self.zcol)\n",
    "        plot_widgets = self.plot_func(self.xlabel, self.ylabel, lines)\n",
    "        self.display_form_func(list(self.selection_widgets.values()) + plot_widgets, self.debug)\n",
    "                \n",
    "        \n",
    "# unit tests\n",
    "class TestInteractivePlot(unittest.TestCase):\n",
    "    def test_interactive_plot(self):\n",
    "        np.random.seed(0)\n",
    "        size = 1000\n",
    "        \n",
    "        dte = np.random.randint(5, 10, size)\n",
    "        put_call = np.random.choice(['put', 'call'], size)\n",
    "        year = np.random.choice([2018, 2019, 2020, 2021], size)\n",
    "        delta = np.random.uniform(0, 0.5, size)\n",
    "        delta = np.where(put_call == 'call', delta, -delta)\n",
    "        premium = np.abs(delta * 10) * dte + np.random.normal(size=size) * dte / 10\n",
    "        data = pd.DataFrame({'dte': dte, 'put_call': put_call, 'year': year, 'delta': delta, 'premium': premium})\n",
    "        \n",
    "        labels = {'premium': 'Premium $', 'year': 'Year', 'dte': 'Days to Expiry', 'delta_rnd': 'Delta'}\n",
    "        secy_line_config = LineConfig(secondary_y=True)\n",
    "        \n",
    "        ip = InteractivePlot(data, \n",
    "                             labels, \n",
    "                             transform_func=self.transform,\n",
    "                             stat_func=MeanWithCI(ci_level=95),\n",
    "                             plot_func=LineGraphWithDetailDisplay(line_configs={'put': secy_line_config}), debug=True)\n",
    "        \n",
    "        ip.create_pivot('delta_rnd', 'premium', 'put_call', dimensions={'year': 2018, 'dte': None})\n",
    "    \n",
    "    def transform(self, data: pd.DataFrame) -> pd.DataFrame:\n",
    "        np.seterr('raise')\n",
    "        data['delta_rnd'] = percentile_buckets(np.abs(data.delta.values), 10)\n",
    "        return data\n",
    "    \n",
    "        \n",
    "if __name__ == '__main__':\n",
    "    doctest.testmod(optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS)\n",
    "    unittest.main(argv=['first-arg-is-ignored'], exit=False)\n",
    "    print('done')"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.9.13"
  },
  "latex_envs": {
   "LaTeX_envs_menu_present": true,
   "autoclose": false,
   "autocomplete": true,
   "bibliofile": "biblio.bib",
   "cite_by": "apalike",
   "current_citInitial": 1,
   "eqLabelWithNumbers": true,
   "eqNumInitial": 1,
   "hotkeys": {
    "equation": "Ctrl-E",
    "itemize": "Ctrl-I"
   },
   "labels_anchors": false,
   "latex_user_defs": false,
   "report_style_numbering": false,
   "user_envs_cfg": false
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
