Source code for openseries.report

"""Functions related to HTML reports."""

from __future__ import annotations

from inspect import stack
from itertools import cycle
from json import dumps as json_dumps
from logging import getLogger
from pathlib import Path
from secrets import choice
from string import ascii_letters
from typing import TYPE_CHECKING, Any, cast
from warnings import catch_warnings, simplefilter
from webbrowser import open as webbrowser_open

from pandas import DataFrame, Index, Series, Timestamp, concat, isna
from plotly.graph_objs import Bar, Figure, Scatter  # type: ignore[import-untyped]
from plotly.utils import PlotlyJSONEncoder  # type: ignore[import-untyped]

from .html_utils import _get_base_css, _get_plotly_script
from .load_plotly import load_plotly_dict
from .owntypes import (
    CaptorLogoType,
    LiteralBizDayFreq,
    LiteralFrameProps,
    LiteralPlotlyJSlib,
    LiteralPlotlyOutput,
    ValueType,
)

if TYPE_CHECKING:  # pragma: no cover
    from .frame import OpenFrame

logger = getLogger(__name__)

__all__ = ["report_html"]


def calendar_period_returns(
    data: OpenFrame,
    freq: LiteralBizDayFreq = "BYE",
    *,
    relabel: bool = True,
) -> DataFrame:
    """Generate a table of returns with appropriate table labels."""
    copied = data.from_deepcopy()
    copied.resample_to_business_period_ends(freq=freq)
    copied.value_to_ret()
    cldr = copied.tsdf.iloc[1:].copy()
    if relabel:
        if freq.upper() == "BYE":
            cldr.index = Index([d.year for d in cldr.index])
        elif freq.upper() == "BQE":
            cldr.index = Index(
                [Timestamp(d).to_period("Q").strftime("Q%q %Y") for d in cldr.index],
            )
        else:
            cldr.index = Index([d.strftime("%b %y") for d in cldr.index])
    return cldr


def _dumps_plotly(obj: object) -> str:
    return json_dumps(obj, cls=PlotlyJSONEncoder)


def _fmt_dates(idx: Index) -> list[str]:
    return [Timestamp(d).strftime("%Y-%m-%d") for d in idx]


def _metrics_table_html(df: DataFrame) -> str:
    return df.to_html(index=False, escape=False, classes=["metrics"], border=0)


def _get_report_properties_and_labels(
    yearfrac: float,
) -> tuple[list[str], list[str], list[str]]:
    """Get properties and labels based on yearfrac."""
    if yearfrac > 1.0:
        properties = [
            "geo_ret",
            "vol",
            "ret_vol_ratio",
            "sortino_ratio",
            "worst_month",
            "first_indices",
            "last_indices",
        ]
        labels_init = [
            "Return (CAGR)",
            "Volatility",
            "Sharpe Ratio",
            "Sortino Ratio",
            "Worst Month",
            "Comparison Start",
            "Comparison End",
            "Jensen's Alpha",
            "Information Ratio",
            "Tracking Error (weekly)",
            "Capture Ratio (monthly)",
            "Index Beta (weekly)",
        ]
        labels_final = [
            "Return (CAGR)",
            "Year-to-Date",
            "Month-to-Date",
            "Volatility",
            "Sharpe Ratio",
            "Sortino Ratio",
            "Jensen's Alpha",
            "Information Ratio",
            "Tracking Error (weekly)",
            "Index Beta (weekly)",
            "Capture Ratio (monthly)",
            "Worst Month",
            "Comparison Start",
            "Comparison End",
        ]
    else:
        properties = [
            "value_ret",
            "vol",
            "ret_vol_ratio",
            "sortino_ratio",
            "worst",
            "first_indices",
            "last_indices",
        ]
        labels_init = [
            "Return (simple)",
            "Volatility",
            "Sharpe Ratio",
            "Sortino Ratio",
            "Worst Day",
            "Comparison Start",
            "Comparison End",
            "Jensen's Alpha",
            "Information Ratio",
            "Tracking Error (weekly)",
            "Index Beta (weekly)",
        ]
        labels_final = [
            "Return (simple)",
            "Year-to-Date",
            "Month-to-Date",
            "Volatility",
            "Sharpe Ratio",
            "Sortino Ratio",
            "Jensen's Alpha",
            "Information Ratio",
            "Tracking Error (weekly)",
            "Index Beta (weekly)",
            "Worst Day",
            "Comparison Start",
            "Comparison End",
        ]
    return properties, labels_init, labels_final


def _create_line_traces(data: OpenFrame) -> list[Scatter]:
    """Create line traces for the plot."""
    x_line = _fmt_dates(data.tsdf.index)
    line_traces: list[Scatter] = []
    for item, lbl in enumerate(data.columns_lvl_zero):
        line_traces.append(
            Scatter(
                x=x_line,
                y=data.tsdf.iloc[:, item].tolist(),
                hovertemplate=f"{lbl}<br>%{{y:.2%}}<br>%{{x}}<extra></extra>",
                line={"width": 2.5, "dash": "solid"},
                mode="lines",
                name=lbl,
                showlegend=True,
            ),
        )
    return line_traces


def _create_bar_traces(
    data: OpenFrame,
    bar_freq: LiteralBizDayFreq,
) -> list[Bar]:
    """Create bar traces for the plot."""
    quarter_of_year = 0.25
    if data.yearfrac < quarter_of_year:
        tmp = data.from_deepcopy()
        bdf = tmp.value_to_ret().tsdf.iloc[1:]
    else:
        bdf = calendar_period_returns(data=data, freq=bar_freq)

    x_bar = [str(x) for x in bdf.index]
    bar_traces: list[Bar] = []
    for item in range(data.item_count):
        col_name = cast("tuple[str, ValueType]", bdf.iloc[:, item].name)
        bar_traces.append(
            Bar(
                x=x_bar,
                y=bdf.iloc[:, item].tolist(),
                hovertemplate=f"{col_name[0]}<br>%{{y:.2%}}<br>%{{x}}<extra></extra>",
                name=col_name[0],
                showlegend=False,
            ),
        )
    return bar_traces


def _add_jensen_alpha(
    rpt_df: DataFrame,
    data: OpenFrame,
) -> DataFrame:
    """Add Jensen's Alpha to the report dataframe."""
    alpha_frame = data.from_deepcopy()
    alpha_frame.to_cumret()
    with catch_warnings():
        simplefilter("ignore")
        alphas: list[str | float] = [
            alpha_frame.jensen_alpha(
                asset=(aname, ValueType.PRICE),
                market=(alpha_frame.columns_lvl_zero[-1], ValueType.PRICE),
                riskfree_rate=0.0,
            )
            for aname in alpha_frame.columns_lvl_zero[:-1]
        ]
    alphas.append("")
    ar = DataFrame(
        data=alphas,
        index=data.tsdf.columns,
        columns=["Jensen's Alpha"],
    ).T
    return concat([rpt_df, ar])


def _add_information_ratio(
    rpt_df: DataFrame,
    data: OpenFrame,
) -> DataFrame:
    """Add Information Ratio to the report dataframe."""
    ir = data.info_ratio_func()
    ir.name = "Information Ratio"
    ir.iloc[-1] = None
    ir_df = ir.to_frame().T
    return concat([rpt_df, ir_df])


def _add_tracking_error(
    rpt_df: DataFrame,
    data: OpenFrame,
) -> DataFrame:
    """Add Tracking Error to the report dataframe."""
    te_frame = data.from_deepcopy()
    te_frame.resample("7D")
    with catch_warnings():
        simplefilter("ignore")
        te: Series[float] | Series[str] = te_frame.tracking_error_func()
    if te.hasnans:
        te = Series(
            data=[""] * te_frame.item_count,
            index=te_frame.tsdf.columns,
            name="Tracking Error (weekly)",
        )
    else:
        te.iloc[-1] = None
        te.name = "Tracking Error (weekly)"
    te_df = te.to_frame().T
    return concat([rpt_df, te_df])


def _add_capture_ratio(
    rpt_df: DataFrame,
    data: OpenFrame,
    formats: list[str],
) -> tuple[DataFrame, list[str]]:
    """Add Capture Ratio to the report dataframe."""
    crm = data.from_deepcopy()
    crm.resample("ME")
    cru_save = Series(
        data=[""] * crm.item_count,
        index=crm.tsdf.columns,
        name="Capture Ratio (monthly)",
    )
    with catch_warnings():
        simplefilter("ignore")
        try:
            cru: Series[float] | Series[str] = crm.capture_ratio_func(ratio="both")
        except ZeroDivisionError as exc:  # pragma: no cover
            msg = f"Capture ratio calculation error: {exc!s}"  # pragma: no cover
            logger.warning(msg)  # pragma: no cover
            cru = cru_save  # pragma: no cover
    if cru.hasnans:
        cru = cru_save
    else:
        cru.iloc[-1] = None
        cru.name = "Capture Ratio (monthly)"
    cru_df = cru.to_frame().T
    return concat([rpt_df, cru_df]), formats


def _add_beta(
    rpt_df: DataFrame,
    data: OpenFrame,
) -> DataFrame:
    """Add Index Beta to the report dataframe."""
    beta_frame = data.from_deepcopy()
    beta_frame.resample("7D").value_nan_handle("drop")
    beta_frame.to_cumret()
    betas: list[str | float] = [
        beta_frame.beta(
            asset=(bname, ValueType.PRICE),
            market=(beta_frame.columns_lvl_zero[-1], ValueType.PRICE),
        )
        for bname in beta_frame.columns_lvl_zero[:-1]
    ]
    betas.append("")
    br = DataFrame(
        data=betas,
        index=data.tsdf.columns,
        columns=["Index Beta (weekly)"],
    ).T
    return concat([rpt_df, br])


def _add_ytd_mtd(
    rpt_df: DataFrame,
    data: OpenFrame,
) -> DataFrame:
    """Add Year-to-Date and Month-to-Date to the report dataframe."""
    this_year = data.last_idx.year
    this_month = data.last_idx.month
    ytd = data.value_ret_calendar_period(year=this_year).map("{:.2%}".format)
    ytd.name = "Year-to-Date"
    mtd = data.value_ret_calendar_period(year=this_year, month=this_month).map(
        "{:.2%}".format,
    )
    mtd.name = "Month-to-Date"
    ytd_df = ytd.to_frame().T
    mtd_df = mtd.to_frame().T
    return concat([rpt_df, ytd_df, mtd_df])


def _get_output_directory(directory: Path | None) -> Path:
    """Determine the output directory."""
    if directory:
        return Path(directory).resolve()
    if Path.home().joinpath("Documents").exists():
        return Path.home() / "Documents"
    return Path(stack()[1].filename).parent


def _get_plotly_layouts(
    layout_theme: dict[str, Any],
    colorway: list[str],
    item_count: int,
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Get line and bar layouts for plotly."""
    line_layout = dict(layout_theme)
    line_layout.update(
        {
            "colorway": colorway[:item_count] if colorway else None,
            "margin": {"l": 50, "r": 20, "t": 20, "b": 40},
            "xaxis": {"gridcolor": "#EEEEEE", "automargin": True, "tickangle": -45},
            "yaxis": {"tickformat": ".2%", "gridcolor": "#EEEEEE", "automargin": True},
            "showlegend": False,
        },
    )

    bar_layout = dict(layout_theme)
    bar_layout.update(
        {
            "barmode": "group",
            "margin": {"l": 50, "r": 20, "t": 10, "b": 80},
            "xaxis": {"gridcolor": "#EEEEEE", "automargin": True, "tickangle": -45},
            "yaxis": {"tickformat": ".2%", "gridcolor": "#EEEEEE", "automargin": True},
            "showlegend": False,
        },
    )

    return line_layout, bar_layout


def _get_logo_html(logo: CaptorLogoType, *, add_logo: bool) -> str:
    """Get logo HTML."""
    if not add_logo:
        return ""
    try:
        src = cast("dict[str, Any]", logo).get("source", "")
    except (KeyError, AttributeError, TypeError):
        src = ""
    if src:
        return f'<img src="{src}" alt="Captor" style="height:68px;" />'
    return "CAPTOR"


def _get_legend_html(line_traces: list[Scatter], colorway: list[str]) -> str:
    """Generate HTML for the legend at the bottom of the page."""
    legend_items = []
    color_cycle = cycle(colorway or ["#66725B"])
    for trace in line_traces:
        name = trace.name or ""
        color = next(color_cycle)
        legend_items.append(
            f'<div class="legend-item">'
            f'<div class="legend-color" style="background-color:{color};"></div>'
            f"<span>{name}</span>"
            f"</div>",
        )
    if legend_items:
        return f'<div class="legend-container">{"".join(legend_items)}</div>'
    return ""


def _get_css() -> str:
    """Get CSS styles for the HTML report."""
    base_css = _get_base_css()
    return (
        base_css
        + """
    .header{display:grid;grid-template-columns:140px 1fr 140px;gap:12px;
    align-items:start;}
    h1{margin:0;text-align:center;font-size:45px;font-weight:800;}
    .layout{display:grid;grid-template-columns:1.2fr .9fr;
    grid-template-areas:"charts table";gap:22px;align-items:start;margin-top:12px;}
    .charts{grid-area:charts;display:grid;grid-template-rows:auto auto;gap:18px;}
    .table{grid-area:table;}
    .plot{width:100%;height:380px;}
    .plot.bar{height:300px;}
    @media (max-width:980px){
      .page{padding:24px;padding-bottom:24px;}
      .header{grid-template-columns:120px 1fr;}
      h1{font-size:36px;}
      .layout{grid-template-columns:1fr;grid-template-areas:"table" "charts";gap:16px;}
      .plot{height:380px;}
      .plot.bar{height:300px;}
      table.metrics{table-layout:fixed;width:auto;}
      table.metrics thead th{min-width:120px;width:120px;white-space:nowrap;}
      table.metrics thead th:first-child{width:180px;}
      table.metrics tbody td{min-width:120px;width:120px;}
      table.metrics tbody td:first-child{width:180px;}
    }
    table.metrics{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;
    border-radius:4px;overflow:hidden;table-layout:fixed;}
    table.metrics thead th{background:var(--header);color:white;padding:8px 10px;
    font-weight:700;text-align:center;word-wrap:break-word;word-break:break-word;}
    table.metrics thead th:first-child{background:var(--header2);text-align:left;
    width:180px;}
    table.metrics tbody td{padding:7px 10px;border-bottom:1px solid white;
    border-right:1px solid white;text-align:center;background:var(--paper);}
    table.metrics tbody td:first-child{text-align:left;font-weight:600;color:white;
    background:var(--header);width:180px;}
    table.metrics tbody td:last-child{background:var(--cell2);}
    .legend-container{margin-top:24px;padding-top:20px;padding-bottom:16px;
    display:flex;justify-content:center;flex-wrap:wrap;gap:24px;flex-shrink:0;}
    .legend-item{display:flex;align-items:center;gap:8px;font-size:14px;}
    .legend-color{width:20px;height:3px;border-radius:2px;}
    @media (min-width:981px){
      html,body{overflow-y:auto;}
    }
    """
    )


def _write_html_file(
    plotfile: Path,
    html: str,
    *,
    auto_open: bool,
) -> str:
    """Write HTML file and optionally open it."""
    plotfile.parent.mkdir(parents=True, exist_ok=True)
    plotfile.write_text(html, encoding="utf-8")
    if auto_open:
        try:
            webbrowser_open(plotfile.as_uri())
        except OSError as exc:
            logger.warning("Failed to open browser: %s", exc)
    return str(plotfile)


def _generate_html(
    title: str | None,
    css: str,
    plotly_script: str,
    logo_html: str,
    table_html: str,
    line_payload: dict[str, Any],
    bar_payload: dict[str, Any],
    legend_html: str,
) -> str:
    """Generate the HTML string."""
    return f"""<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{title or ""}</title>
<style>{css}</style>
{plotly_script}
</head>
<body>
<div class="page">
  <div class="header">
    <div>{logo_html}</div>
    <div><h1>{title or ""}</h1></div>
    <div></div>
  </div>
  <div class="layout">
    <div class="charts">
      <div id="lineplot" class="plot"></div>
      <div id="barplot" class="plot bar"></div>
    </div>
    <div class="table">{table_html}</div>
  </div>
  {legend_html}
</div>
<script>
const line = {_dumps_plotly(line_payload)};
const bar = {_dumps_plotly(bar_payload)};
Plotly.newPlot("lineplot", line.data, line.layout, line.config);
Plotly.newPlot("barplot", bar.data, bar.layout, bar.config);
window.addEventListener("resize", () => {{
  Plotly.Plots.resize("lineplot");
  Plotly.Plots.resize("barplot");
}});
</script>
</body>
</html>
"""


[docs] def report_html( data: OpenFrame, bar_freq: LiteralBizDayFreq = "BYE", filename: str | None = None, title: str | None = None, directory: Path | None = None, output_type: LiteralPlotlyOutput = "file", include_plotlyjs: LiteralPlotlyJSlib = "cdn", *, auto_open: bool = False, add_logo: bool = True, vertical_legend: bool = True, ) -> tuple[Figure, str]: """Generate a responsive HTML report page with line and bar plots and a table.""" copied = data.from_deepcopy() copied.trunc_frame().value_nan_handle().to_cumret() properties, labels_init, labels_final = _get_report_properties_and_labels( copied.yearfrac, ) line_traces = _create_line_traces(copied) bar_traces = _create_bar_traces(copied, bar_freq) rpt_df = copied.all_properties( properties=cast("list[LiteralFrameProps]", properties), ) rpt_df = _add_jensen_alpha(rpt_df, copied) rpt_df = _add_information_ratio(rpt_df, copied) rpt_df = _add_tracking_error(rpt_df, copied) if copied.yearfrac > 1.0: rpt_df, _ = _add_capture_ratio(rpt_df, copied, []) rpt_df = _add_beta(rpt_df, copied) rpt_df.index = Index(labels_init) rpt_df = _add_ytd_mtd(rpt_df, copied) rpt_df = rpt_df.reindex(labels_final) format_map = { "Return (CAGR)": "{:.2%}", "Return (simple)": "{:.2%}", "Year-to-Date": "{:.2%}", "Month-to-Date": "{:.2%}", "Volatility": "{:.2%}", "Sharpe Ratio": "{:.2f}", "Sortino Ratio": "{:.2f}", "Jensen's Alpha": "{:.2%}", "Information Ratio": "{:.2f}", "Tracking Error (weekly)": "{:.2%}", "Index Beta (weekly)": "{:.2f}", "Capture Ratio (monthly)": "{:.2f}", "Worst Month": "{:.2%}", "Worst Day": "{:.2%}", "Comparison Start": "{:%Y-%m-%d}", "Comparison End": "{:%Y-%m-%d}", } formats = [format_map.get(label, "{:.2f}") for label in labels_final] for item, f in zip(rpt_df.index, formats, strict=False): rpt_df.loc[item] = rpt_df.loc[item].apply( lambda x, fmt=f: ( "" if ( x is None or (not isinstance(x, str) and isna(x)) or (isinstance(x, str) and x.lower() in ("nan", "nan%", "")) ) else ( str(x) if isinstance(x, str) else ( Timestamp(x).strftime("%Y-%m-%d") if "%Y-%m-%d" in fmt and not isinstance(x, str) else fmt.format(x) ) ) ), ) rpt_df.index = Index([f"<b>{x}</b>" for x in rpt_df.index]) rpt_df = rpt_df.reset_index() colmns = ["", *copied.columns_lvl_zero] rpt_df.columns = colmns table_html = _metrics_table_html(rpt_df) dirpath = _get_output_directory(directory=directory) if not filename: filename = "".join(choice(ascii_letters) for _ in range(6)) + ".html" plotfile = dirpath / filename fig_theme, logo = load_plotly_dict() layout_theme = cast("dict[str, Any]", fig_theme.get("layout", {})) colorway: list[str] = cast("dict[str, list[str]]", layout_theme).get( "colorway", [] ) line_layout, bar_layout = _get_plotly_layouts( layout_theme=layout_theme, colorway=colorway, item_count=copied.item_count, ) config = cast("dict[str, Any]", fig_theme.get("config", {})) or {} config = {**config, "responsive": True, "displayModeBar": False} plotly_script = _get_plotly_script(include_plotlyjs=include_plotlyjs) logo_html = _get_logo_html(logo=logo, add_logo=add_logo) css = _get_css() line_payload = { "data": [t.to_plotly_json() for t in line_traces], "layout": line_layout, "config": config, } bar_payload = { "data": [t.to_plotly_json() for t in bar_traces], "layout": bar_layout, "config": config, } if not vertical_legend: logger.debug("Horizontal legend layout requested.") legend_html = _get_legend_html(line_traces=line_traces, colorway=colorway) html = _generate_html( title=title, css=css, plotly_script=plotly_script, logo_html=logo_html, table_html=table_html, line_payload=line_payload, bar_payload=bar_payload, legend_html=legend_html, ) if output_type == "file": output = _write_html_file(plotfile=plotfile, html=html, auto_open=auto_open) else: output = html fig_return = Figure(data=line_traces) return fig_return, output