Explainability¶
The portpy.explain() is what turns PortPy from a "numbers out" library into a teaching tool. Every metric can render a plain-language card describing itself, and every result can carry its own name so .explain() works without you having to remember what produced it.
Three pieces¶
Explanation - a small, frozen dataclass: summary, formula, how_to_read, good_vs_bad, caveats, and an optional interpret function mapping a live value to a one-line verdict.
A registry - a plain dict[str, Explanation], populated by register(Explanation(...)) calls sitting at the bottom of every metrics module. The text is hand-written prose placed into the source at import time - there's no dynamic generation, no docstring parsing, no LLM in the loop. For example, in src/portpy/metrics/returns.py:
register(
Explanation(
name="total_return",
category="metric",
summary="The compounded percentage gain or loss over the entire period...",
formula="prod(1 + r_t) - 1",
how_to_read="Read directly as a percentage: 0.35 means +35% over the whole period.",
...
interpret=lambda v: f"{v:+.1%} total over the period" + (" (a net loss)" if v < 0 else ""),
)
)
MetricResult - a float subclass returned when you pass as_result=True. It behaves exactly like a plain float everywhere (arithmetic, comparisons, formatting, numpy/pandas interop), but also carries the metric's registered name and an .explain() method.
Using it¶
sharpe = portfolio.metrics.sharpe_ratio(as_result=True)
float(sharpe) # 0.74 - just a number
sharpe.interpretation # "sub-par" - the one-line verdict, computed from the live value
sharpe.explain() # the full card, printed
portpy.explain(...) is the single dispatch point, and accepts several kinds of input:
import portpy
portpy.explain("sharpe_ratio") # look up by registered name directly
portpy.explain(sharpe) # a MetricResult - uses its .name and its own value
portpy.explain(comparison_df) # any object with `.attrs["portpy_explanation"]`,
# e.g. the DataFrame returned by compare_to_benchmark()
Note
"Importing portpy.explain" from portpy import Portfolio also binds the name explain on the portpy package to the function, not the module (see portpy/__init__.py's
from portpy.explain import explain). To reach the registry helpers directly (available(), get(), register(), Explanation, MetricResult), import them from
the submodule explicitly:
Listing what's registered¶
from portpy.explain import available
available("metric") # every registered metric name
available("function") # non-scalar/utility functions (rolling_metric, describe, monitor, ...)
available("model") # every optimizer/estimator/management model type (mean_variance, capm, rebalance, ...)
Extending it¶
Any function can register a card - this isn't limited to portpy.metrics. portpy.models (estimators, optimization, construction, management) registers one Explanation per model type, tagged category="model", exactly the same way - see Construction, optimization & management. Charts never register their own Explanation: no name is ever tagged category="chart" - a chart instead calls visualization.base.attach_explain(fig, name) to recycle whichever metric's or model's card it's built from (portpy.strategies is expected to follow the category="model"-style pattern too, once it ships).