"""
Deterministic visualization helpers for the allocation layer.
Every helper here takes objects the allocation API already returns - a
bankroll history (or the :class:`~keeks.bankroll.BankRoll` that owns one),
an :class:`~keeks.allocation.base.AllocationResult`, a weight matrix, a
covariance matrix, the hand-rolled HRP linkage, a scenario matrix - and
draws one figure, returning the :class:`matplotlib.axes.Axes` it drew on so
callers can style further, save, or embed.
The helpers are deterministic by construction: series and legends sort by
name, colors come from one small fixed :data:`PALETTE`, and nothing reads or
writes matplotlib's global state. Figures are plain
:class:`matplotlib.figure.Figure` objects - never pyplot-managed - so
repeated calls never leak figures into pyplot's registry, and the helpers
are safe to call inside simulation loops and headless (Agg) test runs.
"""
import operator
from collections.abc import Mapping
from typing import TYPE_CHECKING
import matplotlib.axes
import matplotlib.figure
import numpy as np
from keeks.allocation.base import (
_validate_covariance,
_validate_mean,
_validate_scenarios,
_validate_weights,
)
from keeks.allocation.moments import MeanVariance
from keeks.utils import PROBABILITY_SUM_TOLERANCE, _require_finite
if TYPE_CHECKING:
from keeks.bankroll import BankRoll
__author__ = "willmcginnis"
# One small fixed palette: helpers draw from these in order, so a figure is
# a pure function of its inputs and repeated runs replay identically.
PALETTE = (
"#1f77b4", # blue
"#d62728", # red
"#2ca02c", # green
"#9467bd", # purple
"#ff7f0e", # orange
"#8c564b", # brown
"#e377c2", # pink
"#7f7f7f", # gray
)
# The fixed diverging colormap for correlation heatmaps: symmetric around
# zero, matching the correlation scale the color reads.
COLORMAP = "RdBu_r"
__all__ = [
"COLORMAP",
"PALETTE",
"bankroll_paths",
"correlation_heatmap",
"dendrogram",
"drawdown_history",
"efficient_frontier",
"risk_contributions",
"scenario_losses",
"weight_evolution",
]
def _new_axes(xlabel: str, ylabel: str) -> matplotlib.axes.Axes:
"""
Open a fresh figure and label its axes, touching no global state.
The figure is a bare :class:`matplotlib.figure.Figure`, not a pyplot
figure: nothing registers it in pyplot's global manager, so a helper
called inside a simulation loop cannot leak figures and a headless run
needs no close bookkeeping.
"""
figure = matplotlib.figure.Figure()
axes = figure.add_subplot()
axes.set_xlabel(xlabel)
axes.set_ylabel(ylabel)
return axes
def _history_array(
history: "np.typing.ArrayLike | BankRoll", label: str = "History"
) -> np.ndarray:
"""
Coerce a bankroll history to a validated one-dimensional float array.
A :class:`~keeks.bankroll.BankRoll` (or any object exposing a
``history`` attribute) is unwrapped first, so callers can pass either
the recorded list or the bankroll that owns it. Histories are
nonnegative by construction - a bankroll never settles below zero -
and anything else is rejected loudly.
Parameters
----------
history : sequence of float or object
The bankroll history, or the object whose ``history`` attribute
holds one.
label : str
The name the validation errors carry.
Returns
-------
numpy.ndarray
The validated history as a one-dimensional nonnegative float array.
Raises
------
ValueError
If the history is not a non-empty one-dimensional sequence of
finite nonnegative numbers.
"""
attribute = getattr(history, "history", None)
if attribute is not None:
history = attribute
try:
values = np.asarray(history, dtype=float)
except (TypeError, ValueError) as exc:
raise ValueError(f"{label} must be a finite sequence") from exc
if values.ndim != 1:
raise ValueError(f"{label} must be one-dimensional")
if values.size == 0:
raise ValueError(f"{label} must be non-empty")
if not np.all(np.isfinite(values)):
raise ValueError(f"{label} must contain only finite values")
if np.any(values < 0):
raise ValueError(
f"{label} must be nonnegative: a bankroll never goes below zero"
)
return values
def _validate_weight_history(weights: np.typing.ArrayLike) -> np.ndarray:
"""
Validate a per-period weight matrix and return it as a float array.
One row per period, one column per option, every entry in ``[0, 1]``
and every row summing to no more than one within
``PROBABILITY_SUM_TOLERANCE`` - the same long-only contract
:func:`keeks.allocation.base._validate_weights` enforces on a single
vector, read across time.
Parameters
----------
weights : array-like
The ``(periods, options)`` weight matrix.
Returns
-------
numpy.ndarray
The validated matrix as a two-dimensional float array.
Raises
------
ValueError
If the matrix is not a non-empty two-dimensional sequence of finite
numbers, any entry falls outside ``[0, 1]``, or any row sums above
``1 + PROBABILITY_SUM_TOLERANCE``.
"""
try:
weights = np.asarray(weights, dtype=float)
except (TypeError, ValueError) as exc:
raise ValueError("Weights must be a finite sequence") from exc
if weights.ndim != 2:
raise ValueError(
"Weights must be two-dimensional: one row per period, one column per option"
)
if weights.size == 0:
raise ValueError("Weights must be non-empty")
if not np.all(np.isfinite(weights)):
raise ValueError("Weights must contain only finite values")
if np.any((weights < 0) | (weights > 1)):
raise ValueError("Weights must be between 0 and 1")
if np.any(weights.sum(axis=1) > 1 + PROBABILITY_SUM_TOLERANCE):
raise ValueError("Weights must sum to no more than one in every period")
return weights
def _validate_grid(grid: np.typing.ArrayLike) -> np.ndarray:
"""
Validate a grid of risk-aversion values and return it as a float array.
Parameters
----------
grid : sequence of float
The risk aversions the efficient frontier sweeps. Must be a
non-empty one-dimensional sequence of finite positive numbers.
Returns
-------
numpy.ndarray
The validated grid as a one-dimensional float array.
Raises
------
ValueError
If the grid is not a non-empty one-dimensional sequence of finite
positive numbers.
"""
try:
grid = np.asarray(grid, dtype=float)
except (TypeError, ValueError) as exc:
raise ValueError("Grid must be a finite sequence") from exc
if grid.ndim != 1 or grid.size == 0:
raise ValueError("Grid must be a non-empty one-dimensional sequence")
if not np.all(np.isfinite(grid)) or np.any(grid <= 0):
raise ValueError("Grid must contain only positive finite risk aversions")
return grid
def _validate_linkage(linkage: np.typing.ArrayLike) -> np.ndarray:
"""
Validate an agglomerative linkage matrix and return it as a float array.
The ``(n - 1, 4)`` matrix
:func:`keeks.allocation.hierarchical._agglomerative_linkage` produces:
one row per merge, ``[first_id, second_id, distance, size]``, where
leaves carry their option index and the merge on row ``k`` forms
cluster ``n + k``. A single option has nothing to merge - the empty
``(0, 4)`` matrix - and is the one empty shape accepted.
Parameters
----------
linkage : array-like
The linkage matrix.
Returns
-------
numpy.ndarray
The validated linkage as a two-dimensional float array.
Raises
------
ValueError
If the matrix is not two-dimensional with four columns, is not
finite, or carries a negative merge distance.
"""
try:
linkage = np.asarray(linkage, dtype=float)
except (TypeError, ValueError) as exc:
raise ValueError("Linkage must be a finite sequence") from exc
if linkage.ndim != 2 or linkage.shape[1] != 4:
raise ValueError(
"Linkage must have four columns: [first_id, second_id, distance, size]"
)
if linkage.size == 0:
return linkage
if not np.all(np.isfinite(linkage)):
raise ValueError("Linkage must contain only finite values")
if np.any(linkage[:, 2] < 0):
raise ValueError("Linkage distances must be nonnegative")
return linkage
def _leaf_positions(linkage: np.ndarray, option_count: int) -> dict[int, float]:
"""
Lay out every node of the linkage tree on the x-axis, iteratively.
Leaves sit at consecutive integer positions in the tree's left-to-right
order - the quasi-diagonal order the linkage encodes - and each cluster
sits at the midpoint of its two children. The walk is an explicit-stack
post-order, so deep trees cannot overflow the recursion limit.
Returns a dict mapping every node id (leaves ``0..n-1``, clusters
``n..2n-2``) to its x position; every node is reachable from the root,
which is why no unknown id can survive.
"""
positions: dict[int, float] = {}
root = 2 * option_count - 2
stack = [(root, False)]
while stack:
node, expanded = stack.pop()
if node < option_count:
positions[node] = float(len(positions))
elif expanded:
row = linkage[node - option_count]
positions[node] = (positions[int(row[0])] + positions[int(row[1])]) / 2.0
else:
stack.append((node, True))
stack.append((int(linkage[node - option_count, 1]), False))
stack.append((int(linkage[node - option_count, 0]), False))
return positions
[docs]
def bankroll_paths(
histories: "Mapping[str, np.typing.ArrayLike | BankRoll]"
" | np.typing.ArrayLike | BankRoll",
log_scale: bool = True,
) -> matplotlib.axes.Axes:
"""
Plot the growth curves of one or more bankroll histories.
Each history becomes one line against its period index. Series sort by
name - draw order and legend alike - so a figure is a pure function of
its inputs, and colors cycle through :data:`PALETTE`. The y-axis is
logarithmic by default: compounding bankrolls span orders of magnitude,
and zero entries (a bankrupt period) are simply clipped off a log axis.
Pass ``log_scale=False`` for a linear axis.
Parameters
----------
histories : mapping or sequence
Either a mapping of series name to history - each history a
sequence of nonnegative bankroll values, or the
:class:`~keeks.bankroll.BankRoll` whose ``history`` attribute holds
one - or a single bare history, which plots under the name
``"bankroll"``.
log_scale : bool, default=True
Whether the y-axis is logarithmic.
Returns
-------
matplotlib.axes.Axes
The axes the paths were drawn on, one line per series, legend
sorted by name.
Raises
------
ValueError
If a history is not a non-empty one-dimensional sequence of finite
nonnegative numbers, or the mapping is empty.
Examples
--------
>>> from keeks import BankRoll
>>> from keeks.allocation.plots import bankroll_paths
>>> bankroll = BankRoll(initial_funds=1000.0)
>>> bankroll.deposit(250.0)
>>> axes = bankroll_paths({"fixed": bankroll})
>>> [text.get_text() for text in axes.get_legend().get_texts()]
['fixed']
>>> axes.get_yscale()
'log'
"""
if isinstance(histories, Mapping):
series = {
str(name): _history_array(history, label=f"History for {name!r}")
for name, history in histories.items()
}
if not series:
raise ValueError("At least one bankroll history is required")
else:
series = {"bankroll": _history_array(histories)}
axes = _new_axes("Period", "Bankroll")
for index, (name, values) in enumerate(sorted(series.items())):
axes.plot(
np.arange(values.size, dtype=float),
values,
color=PALETTE[index % len(PALETTE)],
label=name,
)
if log_scale:
axes.set_yscale("log")
axes.legend()
return axes
[docs]
def drawdown_history(history: "np.typing.ArrayLike | BankRoll") -> matplotlib.axes.Axes:
"""
Plot the peak-to-trough drawdown curve of a bankroll history.
The drawdown at each entry is the fractional loss from the best
bankroll seen so far - zero at every new peak, one at a total wipeout -
read as ``(running_peak - value) / running_peak``. Entries before the
first positive value (an unfunded bankroll) carry a zero drawdown:
with nothing ever banked there is nothing to lose.
Parameters
----------
history : sequence of float or object
The bankroll history - a sequence of nonnegative values, or the
:class:`~keeks.bankroll.BankRoll` whose ``history`` attribute holds
one.
Returns
-------
matplotlib.axes.Axes
The axes the curve was drawn on, with the drawdown line and a light
fill beneath it.
Raises
------
ValueError
If the history is not a non-empty one-dimensional sequence of
finite nonnegative numbers.
Examples
--------
>>> from keeks.allocation.plots import drawdown_history
>>> axes = drawdown_history([1000.0, 1250.0, 1000.0])
>>> len(axes.lines)
1
>>> round(float(axes.lines[0].get_ydata().max()), 4)
0.2
"""
values = _history_array(history)
peaks = np.maximum.accumulate(values)
# Masked assignment, not np.where: dividing inside np.where still
# evaluates every element, and 0/0 on an unfunded prefix would warn.
drawdowns = np.zeros_like(values)
positive = peaks > 0
drawdowns[positive] = (peaks[positive] - values[positive]) / peaks[positive]
periods = np.arange(values.size, dtype=float)
axes = _new_axes("Period", "Drawdown")
axes.fill_between(periods, 0.0, drawdowns, color=PALETTE[0], alpha=0.3)
axes.plot(periods, drawdowns, color=PALETTE[0], label="Drawdown")
axes.legend()
return axes
[docs]
def weight_evolution(weights: np.typing.ArrayLike) -> matplotlib.axes.Axes:
"""
Plot long-only weights through time as a stacked area.
One band per option, stacked bottom-up in option order: the stack's top
edge is the total invested fraction, and the gap down to one is the
cash held at zero return. The form fits the layer's weight contract
exactly - weights are nonnegative and sum to no more than one - which
is why the y-axis is pinned to ``[0, 1]``.
Parameters
----------
weights : array-like
The ``(periods, options)`` matrix of long-only weights, every entry
in ``[0, 1]`` and every row summing to no more than one within
``PROBABILITY_SUM_TOLERANCE``.
Returns
-------
matplotlib.axes.Axes
The axes the areas were drawn on, one labeled band per option with
colors cycling through :data:`PALETTE`.
Raises
------
ValueError
If the matrix is not a non-empty two-dimensional sequence of finite
numbers, any entry falls outside ``[0, 1]``, or any row sums above
``1 + PROBABILITY_SUM_TOLERANCE``.
Examples
--------
>>> from keeks.allocation.plots import weight_evolution
>>> axes = weight_evolution([[0.5, 0.25], [0.25, 0.5]])
>>> [text.get_text() for text in axes.get_legend().get_texts()]
['Option 0', 'Option 1']
>>> axes.get_ylim() == (0.0, 1.0)
True
"""
matrix = _validate_weight_history(weights)
option_count = matrix.shape[1]
periods = np.arange(matrix.shape[0], dtype=float)
axes = _new_axes("Period", "Weight")
axes.stackplot(
periods,
matrix.T,
labels=[f"Option {index}" for index in range(option_count)],
colors=[PALETTE[index % len(PALETTE)] for index in range(option_count)],
)
axes.set_ylim(0.0, 1.0)
axes.legend(loc="upper right")
return axes
[docs]
def risk_contributions(
weights: np.typing.ArrayLike, covariance: np.typing.ArrayLike
) -> matplotlib.axes.Axes:
"""
Plot each option's share of the portfolio's risk as a bar chart.
The risk contribution of option ``i`` is
``w_i (Sigma w)_i / (w' Sigma w)`` - the share of the portfolio variance
the option's own exposure drives. The shares sum to one, and the equal
risk contribution portfolio (:class:`~keeks.allocation.moments.RiskBudgeting`
with equal budgets) is the book where every bar reads ``1 / N``.
Parameters
----------
weights : array-like
The long-only weight vector - an
:class:`~keeks.allocation.base.AllocationResult`'s ``weights`` or
any equivalent sequence, validated like every allocation weight.
covariance : array-like
The covariance matrix of the options' simple returns, matching the
weight vector's length.
Returns
-------
matplotlib.axes.Axes
The axes the bars were drawn on, one bar per option in option
order.
Raises
------
ValueError
If the weights or the covariance are invalid, they disagree on the
option count, or the portfolio carries no variance to attribute.
Examples
--------
Equal weights on diagonal-variance options of 4% and 1% put 80% of the
risk in the riskier option:
>>> from keeks.allocation.plots import risk_contributions
>>> axes = risk_contributions([0.5, 0.5], [[0.04, 0.0], [0.0, 0.01]])
>>> len(axes.patches)
2
>>> round(float(sum(patch.get_height() for patch in axes.patches)), 12)
1.0
"""
weights = np.asarray(_validate_weights(weights))
covariance = _validate_covariance(covariance, option_count=weights.size)
marginal = covariance @ weights
variance = float(weights @ marginal)
if variance <= 0:
raise ValueError(
"Portfolio variance must be positive to attribute risk: the "
"weights carry no exposure or the covariance carries no variance"
)
shares = weights * marginal / variance
option_count = weights.size
axes = _new_axes("Option", "Risk contribution share")
axes.bar(
range(option_count),
shares,
color=PALETTE[0],
tick_label=[f"Option {index}" for index in range(option_count)],
)
return axes
[docs]
def efficient_frontier(
mean: np.typing.ArrayLike,
covariance: np.typing.ArrayLike,
grid: np.typing.ArrayLike,
) -> matplotlib.axes.Axes:
"""
Plot the long-only mean-variance efficient frontier with the options.
The frontier curve solves :class:`~keeks.allocation.moments.MeanVariance`
at every risk aversion in ``grid`` and reads each optimum's
``(volatility, expected return)`` pair; the points sort by volatility,
so the curve is drawn low-volatility first regardless of the grid's
order. The individual options plot as a scatter at their own
``(volatility, expected return)``. Like every scipy-gated method,
solving requires the ``keeks[allocation]`` optional extra - the first
:class:`MeanVariance` construction raises the pointed ``ImportError``
without it.
Parameters
----------
mean : array-like
The expected simple return of each option.
covariance : array-like
The covariance matrix of the options' simple returns, matching the
mean's length.
grid : sequence of float
The positive risk aversions to sweep, one frontier point each.
Returns
-------
matplotlib.axes.Axes
The axes the frontier was drawn on: the frontier line plus one
labeled point per option.
Raises
------
ImportError
When scipy is not installed; install the ``keeks[allocation]``
extra.
ValueError
If the mean, the covariance, or the grid is invalid.
Examples
--------
>>> from keeks.allocation.plots import efficient_frontier
>>> axes = efficient_frontier(
... [0.02, 0.01], [[0.04, 0.0], [0.0, 0.01]], [0.5, 1.0, 2.0]
... )
>>> [text.get_text() for text in axes.get_legend().get_texts()]
['Frontier', 'Option 0', 'Option 1']
"""
mean = _validate_mean(mean)
covariance = _validate_covariance(covariance, option_count=mean.size)
grid = _validate_grid(grid)
frontier = []
for risk_aversion in grid:
result = MeanVariance(
mean, covariance, risk_aversion=float(risk_aversion)
).optimize()
frontier.append((float(result.volatility), float(result.weights @ mean)))
# Sweeping the risk aversion moves the optimum monotonically toward
# lower volatility; sorting makes the drawn curve monotone too, immune
# to the grid's order or solver noise at the rounding level.
frontier.sort()
axes = _new_axes("Volatility", "Expected return")
axes.plot(
[point[0] for point in frontier],
[point[1] for point in frontier],
color=PALETTE[0],
label="Frontier",
)
volatilities = np.sqrt(np.diag(covariance))
for index in range(mean.size):
axes.scatter(
[volatilities[index]],
[mean[index]],
color=PALETTE[1],
label=f"Option {index}",
)
axes.legend()
return axes
[docs]
def correlation_heatmap(covariance: np.typing.ArrayLike) -> matplotlib.axes.Axes:
"""
Plot a covariance matrix as a correlation heatmap.
The covariance rescales to a correlation matrix through the diagonal
volatilities (clipped to ``[-1, 1]`` against floating-point noise) and
draws as a square image on the fixed ``"RdBu_r"`` diverging colormap,
symmetric about zero on the ``[-1, 1]`` color scale.
Parameters
----------
covariance : array-like
The covariance matrix of the options' simple returns. Must have
strictly positive variances - the correlation divides by each
option's volatility.
Returns
-------
matplotlib.axes.Axes
The axes the heatmap was drawn on, with the colorbar attached.
Raises
------
ValueError
If the covariance is invalid or carries a zero variance.
Examples
--------
>>> from keeks.allocation.plots import correlation_heatmap
>>> axes = correlation_heatmap([[0.04, 0.004], [0.004, 0.01]])
>>> len(axes.images)
1
>>> axes.images[0].get_clim()
(-1.0, 1.0)
"""
covariance = _validate_covariance(covariance)
variances = np.diag(covariance)
if np.any(variances <= 0):
raise ValueError(
"Covariance must have strictly positive variances: the "
"correlation divides by each option's volatility"
)
std = np.sqrt(variances)
correlation = np.clip(covariance / np.outer(std, std), -1.0, 1.0)
size = covariance.shape[0]
axes = _new_axes("Option", "Option")
image = axes.imshow(correlation, vmin=-1.0, vmax=1.0, cmap=COLORMAP)
axes.figure.colorbar(image, ax=axes)
axes.set_xticks(range(size))
axes.set_yticks(range(size))
return axes
[docs]
def dendrogram(linkage: np.typing.ArrayLike) -> matplotlib.axes.Axes:
"""
Plot the cluster tree an agglomerative linkage matrix describes.
The input is the ``(n - 1, 4)`` linkage matrix
:func:`keeks.allocation.hierarchical._agglomerative_linkage` produces
and :class:`~keeks.allocation.hierarchical.HierarchicalRiskParity`
exposes as its ``linkage`` attribute - one row per merge, leaves at
height zero. Each merge draws its u-shaped connector between the two
children at the merge distance; leaves sit at consecutive integer
positions in the tree's left-to-right (quasi-diagonal) order. A single
option has no merges - the empty ``(0, 4)`` matrix - and draws as one
tick at height zero.
Parameters
----------
linkage : array-like
The ``(n - 1, 4)`` linkage matrix, one row per merge:
``[first_id, second_id, distance, size]``.
Returns
-------
matplotlib.axes.Axes
The axes the tree was drawn on, with one tick per option labeled by
its option index.
Raises
------
ValueError
If the matrix is not two-dimensional with four columns, is not
finite, or carries a negative merge distance.
Examples
--------
>>> from keeks.allocation import HierarchicalRiskParity
>>> from keeks.allocation.plots import dendrogram
>>> strategy = HierarchicalRiskParity([[0.04, 0.004], [0.004, 0.01]])
>>> axes = dendrogram(strategy.linkage)
>>> len(axes.lines)
1
>>> [text.get_text() for text in axes.get_xticklabels()]
['0', '1']
"""
linkage = _validate_linkage(linkage)
option_count = linkage.shape[0] + 1
positions = _leaf_positions(linkage, option_count)
# Node heights: leaves sit at zero, each cluster at its merge distance.
heights = dict.fromkeys(range(option_count), 0.0)
for row_index, row in enumerate(linkage):
heights[option_count + row_index] = float(row[2])
axes = _new_axes("Option", "Linkage distance")
for row_index, row in enumerate(linkage):
first = int(row[0])
second = int(row[1])
height = heights[option_count + row_index]
axes.plot(
[
positions[first],
positions[first],
positions[second],
positions[second],
],
[heights[first], height, height, heights[second]],
color=PALETTE[0],
)
leaves = list(range(option_count))
axes.set_xticks(
[positions[node] for node in leaves],
labels=[str(node) for node in leaves],
)
axes.set_xlim(-0.5, option_count - 0.5)
top = max(heights.values())
axes.set_ylim(0.0, top * 1.02 if top > 0 else 1.0)
return axes
[docs]
def scenario_losses(
scenarios: np.typing.ArrayLike,
weights: np.typing.ArrayLike,
var: float | None = None,
cvar: float | None = None,
bins: int = 30,
) -> matplotlib.axes.Axes:
"""
Plot a histogram of portfolio losses over scenarios with tail markers.
The portfolio's simple returns read off the scenario matrix as
``scenarios @ weights``; losses are their negation. When given, ``var``
and ``cvar`` draw vertical markers - the natural callers are the tail
statistics of the same distribution, e.g.
:attr:`keeks.allocation.scenarios.MeanCVaR.cvar` for the expected tail
loss.
Parameters
----------
scenarios : array-like
The ``(observations, options)`` matrix of joint simple returns.
weights : array-like
The long-only weight vector, one entry per scenario column.
var : float, optional
Where to draw the VaR marker, when given.
cvar : float, optional
Where to draw the CVaR marker, when given.
bins : int, default=30
The histogram's bin count.
Returns
-------
matplotlib.axes.Axes
The axes the histogram was drawn on, with a legend when at least
one marker was drawn.
Raises
------
ValueError
If the scenarios or weights are invalid, they disagree on the
option count, the bins are not a positive integer, or a given
marker is not finite.
Examples
--------
>>> from keeks.allocation.plots import scenario_losses
>>> scenarios = [[0.03, 0.01], [-0.01, 0.02], [0.01, -0.01], [-0.02, -0.02]]
>>> axes = scenario_losses(scenarios, [0.5, 0.5], var=0.005, cvar=0.015)
>>> [text.get_text() for text in axes.get_legend().get_texts()]
['VaR', 'CVaR']
"""
scenarios, _ = _validate_scenarios(scenarios)
weights = np.asarray(_validate_weights(weights))
if weights.size != scenarios.shape[1]:
raise ValueError(
f"Weights must carry exactly {scenarios.shape[1]} entries, one "
f"per option, got {weights.size}"
)
try:
bins = operator.index(bins)
except TypeError as exc:
raise ValueError("Bins must be a positive integer") from exc
if bins <= 0:
raise ValueError("Bins must be a positive integer")
if var is not None:
var = _require_finite(var, "VaR")
if cvar is not None:
cvar = _require_finite(cvar, "CVaR")
losses = -(scenarios @ weights)
axes = _new_axes("Portfolio loss", "Scenarios")
axes.hist(losses, bins=bins, color=PALETTE[0])
if var is not None:
axes.axvline(var, color=PALETTE[1], label="VaR")
if cvar is not None:
axes.axvline(cvar, color=PALETTE[2], label="CVaR")
if var is not None or cvar is not None:
axes.legend()
return axes