Source code for qis.portfolio.reports.factsheet_facade

"""
qis.factsheet - a one-call facade over the four factsheet generators.

This is *additive* convenience sugar: it wraps generate_strategy_factsheet,
generate_strategy_benchmark_factsheet_plt, generate_multi_portfolio_factsheet and
generate_multi_asset_factsheet without changing any of them. All of those remain available and
unchanged for full control; this entry point only removes boilerplate for the common case.

It picks the report archetype from the input type, calibrates every window / regression / regime /
annualisation for the requested reporting frequency via fetch_default_report_kwargs (long vs short
horizon is auto-selected from the reporting span), renders, and optionally writes a PDF.

    import qis
    # one strategy vs benchmark, monthly reporting, full history -> PDF
    qis.factsheet(prices, benchmark_prices=spy, reporting_frequency='monthly', file_name='book')

All qis imports are deferred into the function bodies so importing this module never depends on
qis being fully initialised (keeps the top-level `qis.factsheet` export circular-import safe).
"""
import matplotlib.pyplot as plt
import pandas as pd
from typing import TYPE_CHECKING, List, Optional, Union

if TYPE_CHECKING:  # resolved by typing.get_type_hints and by sphinx autodoc, never at runtime
    from qis.portfolio.reports.config import ReportingFrequency
    from qis.portfolio.multi_portfolio_data import MultiPortfolioData
    from qis.portfolio.portfolio_data import PortfolioData
    from qis.utils.dates import TimePeriod

# report-archetype identifiers (also accepted via the `kind=` override)
KIND_STRATEGY = 'strategy'
KIND_STRATEGY_BENCHMARK = 'strategy_benchmark'
KIND_MULTI_STRATEGY = 'multi_strategy'
KIND_MULTI_ASSET = 'multi_asset'

# keys the facade passes explicitly to the generators - popped from the spread kwargs so that a
# caller-supplied value can never collide into a duplicate keyword argument
_RESERVED_KWARGS = ('portfolio_data', 'multi_portfolio_data', 'prices', 'benchmark',
                    'benchmark_prices', 'time_period', 'factsheet_name', 'backtest_name')


def _parse_reporting_frequency(reporting_frequency):
    """accept a ReportingFrequency or a friendly string ('daily'/'weekly'/'monthly'/'quarterly')."""
    from qis.portfolio.reports.config import ReportingFrequency
    if isinstance(reporting_frequency, ReportingFrequency):
        return reporting_frequency
    aliases = {'daily': ReportingFrequency.DAILY, 'b': ReportingFrequency.DAILY, 'd': ReportingFrequency.DAILY,
               'weekly': ReportingFrequency.WEEKLY, 'w': ReportingFrequency.WEEKLY, 'w-wed': ReportingFrequency.WEEKLY,
               'monthly': ReportingFrequency.MONTHLY, 'm': ReportingFrequency.MONTHLY, 'me': ReportingFrequency.MONTHLY,
               'quarterly': ReportingFrequency.QUARTERLY, 'q': ReportingFrequency.QUARTERLY, 'qe': ReportingFrequency.QUARTERLY}
    key = str(reporting_frequency).strip().lower()
    if key in aliases:
        return aliases[key]
    raise ValueError(f"unsupported reporting_frequency={reporting_frequency!r}; use one of "
                     f"daily/weekly/monthly/quarterly or a ReportingFrequency member")


def _as_fig_list(result) -> List[plt.Figure]:
    """normalise a generator return (single Figure or list) to a list of figures."""
    if result is None:
        return []
    if isinstance(result, (list, tuple)):
        return [f for f in result if f is not None]
    return [result]


def _infer_time_period(data, prices):
    """full-history reporting span when the caller does not pass time_period."""
    import qis as qis
    from qis import PortfolioData, MultiPortfolioData
    if prices is not None:
        return qis.get_time_period(df=prices)
    if isinstance(data, PortfolioData):
        return qis.get_time_period(df=data.get_portfolio_nav())
    if isinstance(data, MultiPortfolioData):
        return qis.get_time_period(df=data.get_navs())
    if isinstance(data, (pd.Series, pd.DataFrame)):
        return qis.get_time_period(df=data)
    raise TypeError(f"cannot infer time_period from {type(data)!r}; pass time_period explicitly")


[docs] def factsheet(data: Union[pd.Series, pd.DataFrame, "PortfolioData", "MultiPortfolioData"], benchmark_prices: Optional[Union[pd.Series, pd.DataFrame]] = None, benchmark: Optional[str] = None, reporting_frequency: Union[str, "ReportingFrequency"] = 'monthly', time_period: Optional["TimePeriod"] = None, kind: Optional[str] = None, data_is_returns: bool = False, long_threshold_years: float = 5.0, add_rates_data: bool = False, file_name: Optional[str] = None, local_path: Optional[str] = None, factsheet_name: Optional[str] = None, **kwargs ) -> Union[str, List[plt.Figure]]: """ Render the appropriate qis factsheet across a chosen reporting frequency in a single call. Archetype is auto-detected from `data` (override with `kind=`): - pd.Series / pd.DataFrame of prices (or returns, if data_is_returns=True) -> multi-asset - PortfolioData -> strategy - MultiPortfolioData -> multi-strategy (pass kind='strategy_benchmark' to render the strategy-vs-benchmark report instead) The reporting frequency ('daily'/'weekly'/'monthly'/'quarterly' or a ReportingFrequency) calibrates every rolling window, regression frequency, regime-classification frequency and annualisation consistently, via fetch_default_report_kwargs; long vs short horizon is selected automatically from the reporting span against long_threshold_years. This is additive: the underlying ``generate_*_factsheet`` functions are unchanged and remain the full-control API. Args: data: prices or returns, PortfolioData, or MultiPortfolioData; drives archetype selection benchmark_prices: reference prices; required for the strategy (PortfolioData) report benchmark: reference column name on the multi-asset path; defaults to the first column of ``data`` reporting_frequency: 'daily', 'weekly', 'monthly', 'quarterly', or a ReportingFrequency time_period: reporting span; defaults to the full history of ``data`` kind: force an archetype identifier; None auto-detects from the type of ``data`` data_is_returns: treat ``data`` and ``benchmark_prices`` as returns and compound them to navs long_threshold_years: spans of at least this length use the long-horizon preset add_rates_data: download the risk-free rate for the excess-return statistics; needs the [data] extra file_name: write the report to a PDF of this name and return its path; without it the figures are returned local_path: directory for the PDF; ignored when ``file_name`` is None factsheet_name: report title, mapped to ``backtest_name`` for the multi-portfolio reports **kwargs: forwarded to the underlying generator; caller values override the preset Returns: the PDF path when ``file_name`` is given, otherwise the list of figures """ import qis as qis from qis import PortfolioData, MultiPortfolioData from qis.portfolio.reports.config import fetch_default_report_kwargs from qis.portfolio.reports.strategy_factsheet import generate_strategy_factsheet from qis.portfolio.reports.strategy_benchmark_factsheet import generate_strategy_benchmark_factsheet_plt from qis.portfolio.reports.multi_strategy_factsheet import generate_multi_portfolio_factsheet from qis.portfolio.reports.multi_assets_factsheet import generate_multi_asset_factsheet rf = _parse_reporting_frequency(reporting_frequency) # resolve the archetype from the input type unless explicitly forced if kind is None: if isinstance(data, MultiPortfolioData): kind = KIND_MULTI_STRATEGY elif isinstance(data, PortfolioData): kind = KIND_STRATEGY elif isinstance(data, (pd.Series, pd.DataFrame)): kind = KIND_MULTI_ASSET else: raise TypeError(f"unsupported data type {type(data)!r}; pass prices/returns, " f"PortfolioData or MultiPortfolioData (or set kind=)") # normalise raw price/return input for the multi-asset path prices = None if kind == KIND_MULTI_ASSET: if not isinstance(data, (pd.Series, pd.DataFrame)): raise TypeError(f"kind='{KIND_MULTI_ASSET}' expects prices/returns, got {type(data)!r}") prices = qis.returns_to_nav(data) if data_is_returns else data if isinstance(prices, pd.Series): prices = prices.to_frame() if data_is_returns and benchmark_prices is not None: benchmark_prices = qis.returns_to_nav(benchmark_prices) # default the regime/beta reference to the first column when none is supplied if benchmark is None and benchmark_prices is None and prices.shape[1] >= 1: benchmark = str(prices.columns[0]) if time_period is None: time_period = _infer_time_period(data, prices) # frequency-calibrated preset kwargs; caller-supplied kwargs win, then drop any keys the facade # passes explicitly so they cannot collide into duplicate keyword arguments report_kwargs = fetch_default_report_kwargs(time_period=time_period, reporting_frequency=rf, long_threshold_years=long_threshold_years, add_rates_data=add_rates_data) report_kwargs = qis.update_kwargs(report_kwargs, kwargs) report_kwargs = {k: v for k, v in report_kwargs.items() if k not in _RESERVED_KWARGS} if kind == KIND_STRATEGY: if benchmark_prices is None: raise ValueError("kind='strategy' (PortfolioData) requires benchmark_prices") result = generate_strategy_factsheet(portfolio_data=data, benchmark_prices=benchmark_prices, time_period=time_period, factsheet_name=factsheet_name, **report_kwargs) elif kind == KIND_STRATEGY_BENCHMARK: if not isinstance(data, MultiPortfolioData): raise TypeError("kind='strategy_benchmark' requires a MultiPortfolioData (strategy + benchmark)") result = generate_strategy_benchmark_factsheet_plt(multi_portfolio_data=data, time_period=time_period, backtest_name=factsheet_name, **report_kwargs) elif kind == KIND_MULTI_STRATEGY: if not isinstance(data, MultiPortfolioData): raise TypeError("kind='multi_strategy' requires a MultiPortfolioData") result = generate_multi_portfolio_factsheet(multi_portfolio_data=data, time_period=time_period, backtest_name=factsheet_name, **report_kwargs) elif kind == KIND_MULTI_ASSET: result = generate_multi_asset_factsheet(prices=prices, benchmark_prices=benchmark_prices, benchmark=benchmark, time_period=time_period, factsheet_name=factsheet_name, **report_kwargs) else: raise ValueError(f"unsupported kind={kind!r}; use one of " f"{(KIND_STRATEGY, KIND_STRATEGY_BENCHMARK, KIND_MULTI_STRATEGY, KIND_MULTI_ASSET)}") figs = _as_fig_list(result) if file_name is not None: if local_path is None: local_path = qis.local_path.get_output_path() return qis.save_figs_to_pdf(figs=figs, file_name=file_name, local_path=local_path) return figs