"""Visualisation -- charts of the IFRS 17 figures the engine produces.
Turn a measurement, a reconciliation or a stochastic result into a chart.
The measurement and reconciliation charts dispatch on the result type --
GMM, PAA, VFA and reinsurance held each draw their own model's quantities
(a PAA result has an LRC and an LIC, not a BEL / RA / CSM split). Every
function draws onto a matplotlib Axes -- it creates one if none is given,
and returns it -- so the charts compose into larger figures and stay easy
to save or restyle.
"""
from __future__ import annotations
from functools import singledispatch
from typing import TYPE_CHECKING
import numpy as np
from fastcashflow._measurement.basis import _require_inception
from fastcashflow._numerics import _norm_ppf
from fastcashflow._measurement.vfa import _require_settlement_csm
from fastcashflow._measurement import gmm as _gmm
from fastcashflow._measurement import paa as _paa
from fastcashflow._measurement import vfa as _vfa
from fastcashflow._measurement import reinsurance as _reinsurance
if TYPE_CHECKING:
from matplotlib.axes import Axes
from fastcashflow.basis import Basis
from fastcashflow._measurement.stochastic import StochasticResult
__all__ = [
"plot_liability",
"plot_cashflows",
"plot_csm_runoff",
"plot_risk_adjustment",
"plot_analysis_of_change",
"plot_stochastic",
]
# fastcashflow chart palette -- one colour per IFRS 17 quantity, kept
# consistent across every chart.
_COLOR = {
"bel": "#3b6ea5", # blue
"ra": "#e0a458", # amber
"csm": "#2a9d8f", # teal-green
"loss": "#c1466b", # rose
"ink": "#1d2b35", # near-black -- text and axes
"grid": "#e6e8eb", # light grid
"up": "#2a9d8f", # waterfall increase
"down": "#e07a5f", # waterfall decrease
"total": "#52677a", # waterfall opening / closing bars
}
def _plt():
"""Import matplotlib lazily, with a helpful error if it is missing."""
try:
import matplotlib.pyplot as plt
except ImportError as exc: # pragma: no cover
raise ImportError(
"matplotlib is missing -- reinstall with 'pip install fastcashflow'"
) from exc
return plt
def _compact(value: float, _pos: object = None) -> str:
"""Format a single monetary value compactly -- 1.4M, 320K, -184K, ..."""
a = abs(value)
if a >= 1e9:
return f"{value / 1e9:,.1f}B"
if a >= 1e6:
return f"{value / 1e6:,.1f}M"
if a >= 1e3:
return f"{value / 1e3:,.0f}K"
return f"{value:,.0f}"
def _gaussian_kde(data, grid):
"""A Gaussian kernel density estimate -- numpy only, no SciPy.
The bandwidth follows Silverman's rule of thumb.
"""
n = data.size
std = data.std(ddof=1)
q75, q25 = np.percentile(data, [75, 25])
iqr = q75 - q25
spread = min(std, iqr / 1.349) if iqr > 0.0 else std
bandwidth = 0.9 * spread * n ** (-0.2)
z = (grid[:, None] - data[None, :]) / bandwidth
kernel = np.exp(-0.5 * z * z) / np.sqrt(2.0 * np.pi)
return kernel.sum(axis=1) / (n * bandwidth)
def _format_money_axis(ax, axis: str) -> None:
"""Format a whole axis as money in one consistent unit (K, M or B)."""
from matplotlib.ticker import FuncFormatter
lo, hi = ax.get_ylim() if axis == "y" else ax.get_xlim()
peak = max(abs(lo), abs(hi))
div, suffix = 1.0, ""
if peak >= 1e9:
div, suffix = 1e9, "B"
elif peak >= 1e6:
div, suffix = 1e6, "M"
elif peak >= 1e3:
div, suffix = 1e3, "K"
def fmt(value, _pos=None):
if value == 0:
return "0"
text = f"{value / div:,.2f}".rstrip("0").rstrip(".")
return f"{text}{suffix}"
target = ax.yaxis if axis == "y" else ax.xaxis
target.set_major_formatter(FuncFormatter(fmt))
def _axes(ax, figsize: tuple[float, float] = (9.0, 5.5)):
"""Return ``ax``, or a fresh Axes if it is ``None``.
A freshly created figure uses constrained layout so the left-aligned
title, the axis labels and the legend get their own space instead of
crowding the plot. When the caller supplies ``ax`` (composing into their
own figure) layout is their responsibility.
"""
if ax is not None:
return ax
_, ax = _plt().subplots(figsize=figsize, dpi=120, constrained_layout=True)
return ax
def _finish(ax, title, *, xlabel=None, ylabel=None, money_axis="y", title_pad=12):
"""Apply the fastcashflow house style to ``ax``."""
ink = _COLOR["ink"]
ax.set_title(title, fontsize=13, fontweight="bold", color=ink,
loc="left", pad=title_pad)
if xlabel:
ax.set_xlabel(xlabel, fontsize=10, color=ink)
if ylabel:
ax.set_ylabel(ylabel, fontsize=10, color=ink)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(_COLOR["grid"])
ax.tick_params(colors=ink, labelsize=9, length=0)
ax.grid(axis="y", color=_COLOR["grid"], linewidth=0.8)
ax.set_axisbelow(True)
if money_axis in ("x", "y"):
_format_money_axis(ax, money_axis)
return ax
def _legend(ax) -> None:
ax.legend(frameon=False, fontsize=9, labelcolor=_COLOR["ink"])
def _reject(entry: str, expected: str, obj: object) -> TypeError:
"""Build the unsupported-type error for a dispatching chart."""
name = type(obj).__name__
hint = ""
if name.startswith("Portfolio"):
hint = (" -- a portfolio container holds one result per model; pass "
"one model slot's native result instead (e.g. the .gmm slot)")
return TypeError(f"{entry} expects {expected}, got {name}{hint}")
# ---------------------------------------------------------------------------
# Liability components over time
# ---------------------------------------------------------------------------
[문서]
@singledispatch
def plot_liability(measurement, *, ax: Axes | None = None,
title: str = "Liability components over time") -> Axes:
"""Plot the liability components over the contract's life.
Dispatches on the measurement type: a GMM, VFA or reinsurance-held
measurement draws the BEL, RA and CSM trajectories; a PAA measurement
draws the LRC and the LIC (its liability has no BEL / RA / CSM split --
and the LRC line excludes the loss component, whose run-off
:func:`plot_analysis_of_change` shows with
``component="loss_component"``). Each line is the portfolio total of
that component at each month. Needs the trajectories, so measure with
``full=True``.
"""
raise _reject("plot_liability()",
"a GMM, PAA, VFA or reinsurance measurement", measurement)
def _component_lines(series, ax, title):
"""Draw portfolio-total component trajectories as labelled lines.
``series`` is ``(label, colour key, (n_mp, n_time+1) path)`` triples.
"""
ax = _axes(ax)
months = np.arange(series[0][2].shape[1])
ax.axhline(0.0, color=_COLOR["ink"], linewidth=0.8)
for label, color, path in series:
ax.plot(months, path.sum(axis=0), color=_COLOR[color], linewidth=2.2,
label=label)
ax.set_xlim(0, max(int(months[-1]), 1))
_finish(ax, title, xlabel="month", ylabel="amount")
_legend(ax)
return ax
def _bel_ra_csm_lines(measurement, ax, title):
_gmm._require_full(measurement, "plot_liability()")
return _component_lines(
(("BEL", "bel", measurement.bel_path),
("RA", "ra", measurement.ra_path),
("CSM", "csm", measurement.csm_path)), ax, title)
@plot_liability.register
def _(measurement: _gmm.Measurement, *, ax=None,
title="Liability components over time"):
_require_inception(measurement, "plot_liability()")
return _bel_ra_csm_lines(measurement, ax, title)
@plot_liability.register
def _(measurement: _vfa.Measurement, *, ax=None,
title="Liability components over time"):
_require_settlement_csm(measurement, "plot_liability()")
return _bel_ra_csm_lines(measurement, ax, title)
@plot_liability.register
def _(measurement: _reinsurance.Measurement, *, ax=None,
title="Reinsurance-held components over time"):
_require_inception(measurement, "plot_liability()")
return _bel_ra_csm_lines(measurement, ax, title)
@plot_liability.register
def _(measurement: _paa.Measurement, *, ax=None,
title="Liability components over time"):
# The LRC trajectory excludes the loss component (the paragraph-100
# split); the label says so, and plot_analysis_of_change shows the loss
# component's own run-off (component="loss_component").
_require_inception(measurement, "plot_liability()")
_paa._require_full(measurement, "plot_liability()")
return _component_lines(
(("LRC (excl. loss component)", "bel", measurement.lrc_path),
("LIC", "ra", measurement.lic_path)), ax, title)
# ---------------------------------------------------------------------------
# Projected cash flows
# ---------------------------------------------------------------------------
[문서]
@singledispatch
def plot_cashflows(measurement, *, period_months: int = 12,
ax: Axes | None = None,
title: str = "Projected cash flows") -> Axes:
"""Plot the projected money in against the money out.
Dispatches on the measurement type. A GMM, PAA or VFA measurement draws
premium income against claim and expense outgo; a reinsurance-held
measurement draws the ceded streams -- recoveries in against reinsurance
premiums out. The monthly cash flows are aggregated into buckets of
``period_months`` months -- a policy year by default. Money in is drawn
upward, money out downward, and the marked line is the net cash flow
each period. Bucketing keeps a front-loaded month from dominating the
chart while the cash-flow shape stays visible.
"""
raise _reject("plot_cashflows()",
"a GMM, PAA, VFA or reinsurance measurement", measurement)
def _cashflow_bars(income, outgo, in_label, out_label, period_months, ax,
title):
"""Bucket two opposing monthly streams and draw the in / out / net bars."""
if period_months < 1:
raise ValueError(f"period_months must be >= 1, got {period_months}")
ax = _axes(ax)
starts = np.arange(0, income.shape[0], period_months)
income_b = np.add.reduceat(income, starts)
outgo_b = np.add.reduceat(outgo, starts)
x = np.arange(income_b.shape[0])
ax.bar(x, income_b, width=0.62, color=_COLOR["csm"],
label=in_label, zorder=3)
ax.bar(x, -outgo_b, width=0.62, color=_COLOR["down"],
label=out_label, zorder=3)
ax.plot(x, income_b - outgo_b, color=_COLOR["ink"], linewidth=1.6,
marker="o", markersize=4, label="net", zorder=4)
ax.axhline(0.0, color=_COLOR["ink"], linewidth=0.8)
ax.set_xticks(x)
ax.set_xticklabels([str(i + 1) for i in x])
_finish(ax, title,
xlabel="policy year" if period_months == 12 else "period",
ylabel="amount")
_legend(ax)
return ax
def _direct_cashflow_chart(measurement, period_months, ax, title):
cf = measurement.cashflows
premium = cf.premium_cf.sum(axis=0)
# Every monthly insurer outflow, so the "net" line is honest: claims,
# morbidity, annuity, expenses, disability income/lump and surrender value.
# (maturity_cf is a per-policy lump at each policy's term, not a monthly
# series, so it is not placed on this period-binned timeline.)
outgo = (cf.mortality_cf + cf.morbidity_cf + cf.annuity_cf + cf.expense_cf
+ cf.disability_cf + cf.surrender_cf).sum(axis=0)
return _cashflow_bars(premium, outgo, "premiums in",
"claims & expenses out", period_months, ax, title)
@plot_cashflows.register
def _(measurement: _gmm.Measurement, *, period_months=12, ax=None,
title="Projected cash flows"):
_require_inception(measurement, "plot_cashflows()")
_gmm._require_full(measurement, "plot_cashflows()")
return _direct_cashflow_chart(measurement, period_months, ax, title)
@plot_cashflows.register
def _(measurement: _vfa.Measurement, *, period_months=12, ax=None,
title="Projected cash flows"):
_require_settlement_csm(measurement, "plot_cashflows()")
_gmm._require_full(measurement, "plot_cashflows()")
return _direct_cashflow_chart(measurement, period_months, ax, title)
@plot_cashflows.register
def _(measurement: _paa.Measurement, *, period_months=12, ax=None,
title="Projected cash flows"):
_require_inception(measurement, "plot_cashflows()")
_paa._require_full(measurement, "plot_cashflows()")
return _direct_cashflow_chart(measurement, period_months, ax, title)
@plot_cashflows.register
def _(measurement: _reinsurance.Measurement, *, period_months=12, ax=None,
title="Projected ceded cash flows"):
_require_inception(measurement, "plot_cashflows()")
_gmm._require_full(measurement, "plot_cashflows()")
return _cashflow_bars(
measurement.recovery.sum(axis=0),
measurement.reinsurance_premium.sum(axis=0),
"recoveries in", "reinsurance premiums out", period_months, ax, title)
# ---------------------------------------------------------------------------
# CSM run-off
# ---------------------------------------------------------------------------
[문서]
@singledispatch
def plot_csm_runoff(measurement, *, ax: Axes | None = None,
title: str = "CSM run-off") -> Axes:
"""Plot the contractual service margin running off to zero.
Dispatches on the measurement type. A GMM or VFA measurement draws the
unearned profit emerging into the income statement as service is
provided; a reinsurance-held measurement draws its net cost or gain
amortising -- that CSM may be negative, so its axis is not clamped at
zero. A PAA measurement is rejected: the PAA carries no CSM.
"""
raise _reject("plot_csm_runoff()",
"a GMM, VFA or reinsurance measurement", measurement)
def _csm_area(measurement, ax, title, *, clamp):
_gmm._require_full(measurement, "plot_csm_runoff()")
ax = _axes(ax)
csm = measurement.csm_path.sum(axis=0)
months = np.arange(csm.shape[0])
ax.fill_between(months, csm, color=_COLOR["csm"], alpha=0.22)
ax.plot(months, csm, color=_COLOR["csm"], linewidth=2.6)
ax.set_xlim(0, max(int(months[-1]), 1))
if clamp:
ax.set_ylim(bottom=0.0)
else:
ax.axhline(0.0, color=_COLOR["ink"], linewidth=0.8)
_finish(ax, title, xlabel="month", ylabel="CSM")
return ax
@plot_csm_runoff.register
def _(measurement: _gmm.Measurement, *, ax=None, title="CSM run-off"):
_require_inception(measurement, "plot_csm_runoff()")
return _csm_area(measurement, ax, title, clamp=True)
@plot_csm_runoff.register
def _(measurement: _vfa.Measurement, *, ax=None, title="CSM run-off"):
_require_settlement_csm(measurement, "plot_csm_runoff()")
return _csm_area(measurement, ax, title, clamp=True)
@plot_csm_runoff.register
def _(measurement: _reinsurance.Measurement, *, ax=None,
title="Reinsurance CSM run-off"):
_require_inception(measurement, "plot_csm_runoff()")
return _csm_area(measurement, ax, title, clamp=False)
@plot_csm_runoff.register
def _(measurement: _paa.Measurement, *, ax=None, title="CSM run-off"):
raise TypeError(
"plot_csm_runoff() does not apply to the PAA -- a PAA liability has "
"no CSM (the LRC itself carries the unearned profit); "
"plot_liability() shows the LRC running off")
# ---------------------------------------------------------------------------
# Risk adjustment as a confidence level
# ---------------------------------------------------------------------------
[문서]
@singledispatch
def plot_risk_adjustment(measurement, basis: Basis,
*, bands: tuple[float, ...] = (0.75, 0.85),
ax: Axes | None = None,
title: str = "The risk adjustment as a confidence level",
) -> Axes:
"""Plot the risk adjustment as a percentile of the liability distribution.
The confidence-level method models the value arising from non-financial
risk as a normal distribution centred on the best estimate; the risk
adjustment is the margin from that mean out to a chosen percentile. This
chart draws that normal distribution and shades the margin up to each
confidence level in ``bands``. Dispatches on the measurement type: a GMM
measurement requires a confidence-level basis; a VFA measurement's RA is
always this construct (a confidence-level margin for expense risk), as
is a reinsurance-held measurement's (the margin on the ceded claims --
the risk transferred, which *reduces* the net cost, so its margin shades
to the left of the best estimate). A PAA measurement is rejected -- the
PAA carries no explicit risk adjustment.
"""
raise _reject("plot_risk_adjustment()",
"a GMM, VFA or reinsurance measurement", measurement)
def _ra_fan(mu, ra, z_confidence, bands, ax, title, xlabel, side=1.0):
"""Draw the normal distribution and shade the RA margin per band.
``mu`` is the best estimate, ``ra`` the headline risk adjustment and
``z_confidence`` the z-score of the basis confidence level -- so
``sigma = ra / z_confidence`` recovers the implied distribution width.
``side`` is the direction the margin moves the fulfilment value:
``+1.0`` adds to a direct liability (FCF = BEL + RA), ``-1.0`` reduces a
reinsurance-held net cost (FCF = BEL - RA, the risk transferred).
"""
if ra <= 0.0:
raise ValueError("the risk adjustment is zero -- nothing to plot")
sigma = ra / z_confidence
ax = _axes(ax)
x = np.linspace(mu - 3.6 * sigma, mu + 3.6 * sigma, 400)
pdf = np.exp(-0.5 * ((x - mu) / sigma) ** 2) / (sigma * np.sqrt(2.0 * np.pi))
ax.plot(x, pdf, color=_COLOR["bel"], linewidth=2.0, zorder=4)
ax.fill_between(x, pdf, color=_COLOR["bel"], alpha=0.10, zorder=1)
ax.axvline(mu, color=_COLOR["ink"], linewidth=1.6, zorder=5,
label="best estimate (BEL)")
for band in sorted(bands):
z_band = _norm_ppf(band)
percentile = mu + side * z_band * sigma
region = ((x >= mu) & (x <= percentile) if side >= 0.0
else (x >= percentile) & (x <= mu))
ax.fill_between(x[region], pdf[region], color=_COLOR["ra"],
alpha=0.22, zorder=2)
ax.axvline(percentile, color=_COLOR["ra"], linewidth=1.5,
linestyle="--", zorder=5,
label=f"{band:.0%} confidence -- RA {_compact(z_band * sigma)}")
ax.set_ylim(bottom=0.0)
_finish(ax, title, xlabel=xlabel, ylabel="density", money_axis="x")
ax.set_yticks([])
_legend(ax)
return ax
@plot_risk_adjustment.register
def _(measurement: _gmm.Measurement, basis, *, bands=(0.75, 0.85), ax=None,
title="The risk adjustment as a confidence level"):
_require_inception(measurement, "plot_risk_adjustment()")
if basis.ra_method != "confidence_level":
raise ValueError(
"plot_risk_adjustment shows the confidence-level risk "
"adjustment; these basis use the cost-of-capital method"
)
return _ra_fan(float(measurement.bel.sum()), float(measurement.ra.sum()),
_norm_ppf(basis.ra_confidence), bands, ax, title,
"liability from non-financial risk")
@plot_risk_adjustment.register
def _(measurement: _vfa.Measurement, basis, *, bands=(0.75, 0.85), ax=None,
title="The risk adjustment as a confidence level"):
_require_settlement_csm(measurement, "plot_risk_adjustment()")
# The VFA RA is always a confidence-level margin for expense risk
# (z(ra_confidence) x expense_cv x PV(expenses)) -- no method check.
return _ra_fan(float(measurement.bel.sum()), float(measurement.ra.sum()),
_norm_ppf(basis.ra_confidence), bands, ax, title,
"liability from non-financial risk")
@plot_risk_adjustment.register
def _(measurement: _reinsurance.Measurement, basis, *, bands=(0.75, 0.85),
ax=None, title="The risk adjustment as a confidence level"):
_require_inception(measurement, "plot_risk_adjustment()")
# The reinsurance-held RA is always the confidence-level margin on the
# ceded claims -- the risk transferred (paragraph 64) -- no method check.
# It reduces the net cost (FCF = BEL - RA), so the margin shades to the
# left of the best estimate.
return _ra_fan(float(measurement.bel.sum()), float(measurement.ra.sum()),
_norm_ppf(basis.ra_confidence), bands, ax, title,
"reinsurance net cost from non-financial risk", side=-1.0)
@plot_risk_adjustment.register
def _(measurement: _paa.Measurement, basis, *, bands=(0.75, 0.85), ax=None,
title="The risk adjustment as a confidence level"):
raise TypeError(
"plot_risk_adjustment() does not apply to the PAA -- a PAA liability "
"carries no explicit risk adjustment (the LRC is an unearned-premium "
"balance)")
# ---------------------------------------------------------------------------
# Analysis of change (waterfall)
# ---------------------------------------------------------------------------
[문서]
@singledispatch
def plot_analysis_of_change(reconciliation, *, component: str = "csm",
ax: Axes | None = None,
title: str | None = None) -> Axes:
"""Plot one reporting period's analysis of change as a waterfall.
Dispatches on the reconciliation type. A GMM reconciliation bridges
``component`` -- ``"bel"``, ``"ra"`` or ``"csm"`` -- from the opening
balance to the closing balance through the future-service, finance and
release drivers; a VFA or reinsurance reconciliation through finance and
release. A PAA reconciliation selects one of its paragraph-100 blocks:
``component`` is ``"lrc"`` (the default there), ``"loss_component"`` or
``"lic_path"``.
A *settlement* reconciliation (from ``gmm.settle`` / ``vfa.settle`` via
``reconcile``) has no waterfall arm in v1 and is rejected here; its
``str()`` form prints the full paragraph-44 / paragraph-45 table.
"""
raise _reject("plot_analysis_of_change()",
"a GMM, PAA, VFA or reinsurance reconciliation",
reconciliation)
def _waterfall(steps, ax, title, ylabel):
"""Draw an opening -> drivers -> closing waterfall.
``steps`` is ``(label, value)`` pairs: the opening balance, each
driver's signed contribution, and the closing balance.
"""
labels = [label for label, _ in steps]
values = [value for _, value in steps]
opening, closing = values[0], values[-1]
# Running level after the opening bar and after each driver; the last
# one is pinned to the reported closing balance so the final driver bar
# lands exactly on it.
levels = [opening]
for delta in values[1:-1]:
levels.append(levels[-1] + delta)
levels[-1] = closing
spans = [(0.0, opening)]
spans += [(levels[i], levels[i + 1]) for i in range(len(levels) - 1)]
spans += [(0.0, closing)]
ax = _axes(ax)
for i, (lo, hi) in enumerate(spans):
if i in (0, len(spans) - 1):
color = _COLOR["total"]
else:
color = _COLOR["up"] if hi >= lo else _COLOR["down"]
ax.bar(i, hi - lo, bottom=lo, width=0.62, color=color, zorder=3)
for i, level in enumerate(levels):
ax.plot([i + 0.31, i + 0.69], [level, level], color=_COLOR["ink"],
linewidth=1.0, linestyle=(0, (4, 2)), zorder=2)
for i, (lo, hi) in enumerate(spans):
ax.annotate(_compact(values[i]), (i, max(lo, hi)),
textcoords="offset points", xytext=(0, 5), ha="center",
fontsize=8.5, fontweight="bold", color=_COLOR["ink"])
ax.axhline(0.0, color=_COLOR["ink"], linewidth=0.8)
# Extra headroom: bold value labels above bars + space under the title.
ax.margins(y=0.20)
ax.set_xticks(range(len(spans)))
ax.set_xticklabels(labels)
# Waterfalls carry bold value labels above the bars, so the title needs
# more clearance than the line charts (default pad=12).
_finish(ax, title, ylabel=ylabel, title_pad=24)
return ax
def _bel_ra_csm_component(component: str) -> str:
component = component.lower()
if component not in ("bel", "ra", "csm"):
raise ValueError(
f"component must be 'bel', 'ra' or 'csm', got {component!r}"
)
return component
@plot_analysis_of_change.register
def _(reconciliation: _gmm.Reconciliation, *, component="csm", ax=None,
title=None):
r = reconciliation
component = _bel_ra_csm_component(component)
steps = (
("Opening", getattr(r, f"{component}_opening")),
("Future\nservice", getattr(r, f"{component}_future_service")),
("Finance", getattr(r, f"{component}_finance")),
("Release", getattr(r, f"{component}_release")),
("Closing", getattr(r, f"{component}_closing")),
)
if title is None:
title = (f"{component.upper()} analysis of change "
f"-- months {r.month_start + 1}-{r.month_end}")
return _waterfall(steps, ax, title, component.upper())
def _finance_release_waterfall(r, component, ax, title, kind):
component = _bel_ra_csm_component(component)
steps = (
("Opening", getattr(r, f"{component}_opening")),
("Finance", getattr(r, f"{component}_finance")),
("Release", getattr(r, f"{component}_release")),
("Closing", getattr(r, f"{component}_closing")),
)
if title is None:
title = (f"{kind}{component.upper()} analysis of change "
f"-- months {r.month_start + 1}-{r.month_end}")
return _waterfall(steps, ax, title, component.upper())
@plot_analysis_of_change.register
def _(reconciliation: _vfa.Reconciliation, *, component="csm", ax=None,
title=None):
return _finance_release_waterfall(reconciliation, component, ax, title,
"VFA ")
@plot_analysis_of_change.register
def _(reconciliation: _reinsurance.Reconciliation, *, component="csm", ax=None,
title=None):
return _finance_release_waterfall(reconciliation, component, ax, title,
"Reinsurance ")
@plot_analysis_of_change.register
def _(reconciliation: _paa.Reconciliation, *, component="lrc", ax=None,
title=None):
r = reconciliation
blocks = {
"lrc": ("LRC", (
("Opening", r.lrc_opening),
("Premiums", r.premiums),
("Revenue", r.revenue),
("Closing", r.lrc_closing),
)),
"loss_component": ("Loss component", (
("Opening", r.loss_component_opening),
("Released", r.loss_component_release),
("Closing", r.loss_component_closing),
)),
"lic_path": ("LIC", (
("Opening", r.lic_opening),
("Claims\nincurred", r.claims_incurred),
("Claims\npaid", r.claims_paid),
("Closing", r.lic_closing),
)),
}
component = component.lower()
if component not in blocks:
raise ValueError(
"component must be 'lrc', 'loss_component' or 'lic_path', got "
f"{component!r}"
)
label, steps = blocks[component]
if title is None:
title = (f"{label} analysis of change "
f"-- months {r.month_start + 1}-{r.month_end}")
return _waterfall(steps, ax, title, label)
# ---------------------------------------------------------------------------
# Stochastic distribution
# ---------------------------------------------------------------------------
[문서]
def plot_stochastic(result: StochasticResult, *, line: str = "bel",
ax: Axes | None = None, bins: int = 30,
kde: bool = True, title: str | None = None) -> Axes:
"""Plot the distribution of a figure across the stochastic scenarios.
``line`` selects ``"bel"``, ``"ra"``, ``"csm"`` or ``"loss_component"``.
A smooth Gaussian kernel density estimate is drawn over the histogram
unless ``kde`` is ``False``; the dashed line marks the mean.
"""
line = line.lower()
valid = ("bel", "ra", "csm", "loss_component")
if line not in valid:
raise ValueError(f"line must be one of {valid}, got {line!r}")
data = np.asarray(getattr(result, line), dtype=float)
ax = _axes(ax)
_counts, edges, _patches = ax.hist(
data, bins=bins, color=_COLOR["bel"], alpha=0.6,
edgecolor="white", linewidth=0.6, zorder=3,
)
if kde and data.size > 1 and data.std() > 0.0:
grid = np.linspace(data.min(), data.max(), 256)
density = _gaussian_kde(data, grid)
ax.plot(grid, density * data.size * (edges[1] - edges[0]),
color=_COLOR["ink"], linewidth=2.0, zorder=5)
mean = float(data.mean())
ax.axvline(mean, color=_COLOR["loss"], linewidth=1.8, linestyle="--",
zorder=6, label=f"mean {_compact(mean)}")
if title is None:
title = f"{line.upper()} distribution over {data.size} scenarios"
_finish(ax, title, xlabel=line.upper(), ylabel="scenarios",
money_axis="x")
_legend(ax)
return ax